Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1541b77e6 | ||
|
|
7adf43dd03 | ||
|
|
4c754b20da | ||
|
|
ded4e7f1a1 | ||
|
|
598a585eb8 | ||
|
|
4c4bb1a83a | ||
|
|
ed16dda2ec |
@@ -0,0 +1,69 @@
|
||||
# Gitea Actions. See docs/CI.md for what each job needs, the one-time runner setup, and the
|
||||
# known gaps between Gitea Actions and GitHub Actions this file was written around (notably:
|
||||
# timeout-minutes and concurrency are parsed but not enforced by Gitea as of the 1.25 docs, so
|
||||
# the real cap on every step is the `timeout <seconds>` wrapped around it, not the YAML key).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# Needs only a C++20 toolchain and CMake: runs on any Docker-mode act_runner.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10 # label only on Gitea (see docs/CI.md); the real cap is the `timeout` below
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install build dependencies
|
||||
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake'
|
||||
- name: build (-Werror)
|
||||
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||
- name: docs cross-link check
|
||||
run: timeout 60 scripts/check-docs.sh
|
||||
|
||||
# No special runner privilege needed: scripts/valgrind.sh only fails on what valgrind itself
|
||||
# reports, never on doos's own exit code, so it is still a real check even when this container's
|
||||
# own policy blocks the user namespace doos tries to create (see docs/CI.md).
|
||||
valgrind:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install build and valgrind dependencies
|
||||
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake valgrind'
|
||||
- name: build
|
||||
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||
- name: memcheck against the fixed invocation set
|
||||
run: timeout 300 scripts/valgrind.sh "$(pwd)/build/doos"
|
||||
- name: keep the per-step logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: valgrind-reports
|
||||
path: build/valgrind-reports/
|
||||
|
||||
# Real overlayfs, real user/mount/pid namespaces, real cgroup v2 limits, real downloads: needs
|
||||
# the self-hosted host-mode runner set up per docs/CI.md (label "doos-host"), not a container.
|
||||
test:
|
||||
runs-on: [self-hosted, doos-host]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: build
|
||||
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||
- name: end-to-end suite
|
||||
run: timeout 600 tests/run.sh "$(pwd)/build/doos"
|
||||
|
||||
# Named CVE regression set against the binary this run just built. Same runner requirement as
|
||||
# `test`, for the same reason (several checks attach a real container).
|
||||
audit:
|
||||
runs-on: [self-hosted, doos-host]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: build
|
||||
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||
- name: CVE regression checks
|
||||
run: timeout 300 make -C audit -j8 test DOOS="$(pwd)/build/doos"
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||
|
||||
Rules for any AI agent (or human) changing this repository. The user's global rules
|
||||
also apply; this file adds the project-specific ones. Keep this file current: it is
|
||||
@@ -48,11 +48,15 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
|
||||
| These workflow rules | this file |
|
||||
| A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `<!--bench:...-->` number and table in `COMPARISON.md` and `docs/RESEARCH.md`); never type those numbers by hand |
|
||||
| A feature or limit that appears in the comparison tables | `COMPARISON.md` (tables, the gaps list, the conclusion) |
|
||||
| A code path one of the `audit/CVE-*` checks targets (overlay writes, tar extraction, mount/directory creation, net namespace sharing, seccomp denylist) | re-run `make audit`; update that CVE's `README.md` if the verdict's reasoning changed |
|
||||
| Allocation shape of `containerInit`, `setupRoot`, `doMount` or `Sandbox::start` | re-run `make valgrind`; if a suppressed site's stack trace changed, regenerate it (see [valgrind/README.md](valgrind/README.md)) and update `valgrind/doos.supp`'s comment |
|
||||
| A new or renamed markdown file | `scripts/check-docs.sh` DOCS list, the nav line (`**Docs:**`) of every document, `README.md` Project docs |
|
||||
| Image catalog, sources, verification, pinned keys | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
|
||||
| Seccomp list, capability set, `/etc` allowlist | `SPEC.md` Hardening and filesystem, `README.md`, `docs/RESEARCH.md` |
|
||||
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
|
||||
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry |
|
||||
| Anything above, in every release | `CHANGELOG.md` entry (`README.md` points at `VERSION`, nothing to edit there) |
|
||||
| The CI workflow or what a job needs | `.gitea/workflows/ci.yml`, `docs/CI.md` |
|
||||
| The davmount flow, pinned rclone version/key, or what `--mount`/`--davmount` accepts | `SPEC.md` (CLI options, davmount section, limits), `docs/ARCHITECTURE.md` davmount flow, `docs/RESEARCH.md` G13 |
|
||||
|
||||
Docs describe what the system does today. No roadmap text. A gap is listed next to
|
||||
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
|
||||
@@ -78,19 +82,32 @@ the script.
|
||||
|
||||
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
|
||||
slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size
|
||||
freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 delegation). Add a case for every behavior change. A green run is required
|
||||
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
|
||||
installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself).
|
||||
freeze, a real WebDAV round trip through a disposable local `rclone serve webdav` fixture (about 4-5
|
||||
minutes in total, needs network, `pasta`, `gpg`, `unzip` and cgroup v2 delegation). Add a case for every
|
||||
behavior change. A green run is required before release. On Ubuntu 24.04+ the AppArmor profile in
|
||||
`apparmor/doos` must be installed once for the binary being tested (`doos apparmor`; needs sudo, ask the
|
||||
user, do not do it yourself).
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/ci.yml` runs `build`, `valgrind`, `test` and `audit` on every push (Gitea Actions).
|
||||
`test` and `audit` need the self-hosted host-mode runner described in [docs/CI.md](docs/CI.md) (real
|
||||
namespaces, cgroup v2 delegation, the AppArmor profile); the workflow itself never runs `sudo`, same
|
||||
rule as everywhere else. See that file before changing the workflow or what a job needs.
|
||||
|
||||
## Make targets
|
||||
|
||||
`make help` lists them: `build`, `test`, `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
||||
`make help` lists them: `build`, `test`, `audit` (named CVE regression checks against `build/doos`,
|
||||
see [audit/README.md](audit/README.md)), `valgrind` (memcheck against a fixed set of invocations,
|
||||
see [valgrind/README.md](valgrind/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
||||
`charts` (SVGs and generated numbers from that file), `docs`, `release KIND=patch MSG="..."`, `clean`.
|
||||
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/` and `docs/charts/`.
|
||||
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/`, `valgrind/` and `docs/charts/`.
|
||||
|
||||
## Environment notes
|
||||
|
||||
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
|
||||
Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
|
||||
- Build: `cmake -S . -B build && cmake --build build`.
|
||||
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode.
|
||||
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode. `unzip` and `fusermount3`
|
||||
(or `fusermount`) are needed only for `--mount`/`--davmount` against a `dav://`/`davs://`/`http(s)://`
|
||||
source; doos installs its own `rclone` under `$DOOS_HOME/tools/`, never the system's.
|
||||
|
||||
+42
-1
@@ -1,6 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||
|
||||
## 1.6.0 - 2026-10-25
|
||||
- `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source: auto-mounts WebDAV. doos downloads, GPG-verifies against a pinned key and installs `rclone` once under `$DOOS_HOME/tools/` (never the system, never `PATH`, same trust model as the catalog images), then drives it on the host with an on-the-fly connection string (no config file, password passed through `rclone obscure`, never on the mount's command line) before the container's namespaces exist. The mount is deduplicated by target (url + credentials + vendor), reused across containers and attaches, and runs detached so it outlives the `attach` that created it. `--dav-user`, `--dav-pass`, `--dav-vendor` and `--dav-insecure` configure a target without putting credentials in the URL. Tracked as gap G13 in [RESEARCH](docs/RESEARCH.md); measured there against a local `rclone serve webdav` fixture (install, reuse at ~40 ms, write-back durability, and each failure mode: wrong credentials, unreachable server, non-WebDAV endpoint).
|
||||
- `doos davmount list` / `doos davmount umount [--yes] ID`: inspect or tear down a managed WebDAV mount; dry-run by default like `delete`/`image delete`.
|
||||
- Fix (found while building the above): the classic `--mount SRC:DST[:ro]` could not take credentials with `:`/`;` in them because mounts were serialized as one delimited line; container `meta` now stores mounts as indexed keys (one field per line), with the old single-line format still read for containers created under 1.5.0.
|
||||
- Refactor: `curl`/gpg download-and-verify helpers moved from `image.cpp` into `util.{hpp,cpp}` (`fetchText`, `fetchFile`, `TempDir`, `isHex64`, `verifyDetachedSignature`, `verifyClearsignedSignature`) so `davmount.cpp` reuses the exact same, already-tested verification code instead of a second copy; no behavior change (full image signature test suite re-passes).
|
||||
- New dependency, only for davmount: `unzip` (to unpack rclone's release archive) and `fusermount3`/`fusermount` (to unmount it); documented in README/AGENTS.
|
||||
- Tests: 10 new end-to-end checks against a real local WebDAV server (read, write durability, idempotent reuse, `--davmount` with `--dav-user`/`--dav-pass`, read-only, wrong-password classification, unknown vendor rejected, `davmount list`/`umount`).
|
||||
- Docs: [COMPARISON](COMPARISON.md) section 4's "Where Docker is better" no longer lists bind mounts as a doos gap (stale since 1.5.0); charts and generated numbers re-measured.
|
||||
|
||||
## 1.5.0 - 2026-10-24
|
||||
- `--mount SRC:DST[:ro]` (repeatable, max 8): bind-mounts a host directory into the container, outside the `--size` cap. Lets a container reach storage that is not local, for example a WebDAV share mounted on the host first with `rclone mount` or `davfs2`; doos never speaks a network filesystem protocol itself, it only bind-mounts what the host already mounted. Changeable like `--memory`/`--pids`/`--cpus`: replaces the whole mount list, applies at the next start. Tracked as gap G12 in [RESEARCH](docs/RESEARCH.md); 5 new end-to-end checks (read, write, read-only, missing source rejected, too many rejected).
|
||||
- Docs: [COMPARISON](COMPARISON.md) section 1 and the gaps list updated (doos now has host-dir bind mounts); charts and generated numbers re-measured (`make bench && make charts`).
|
||||
|
||||
## 1.4.0 - 2026-10-24
|
||||
- CI: `.gitea/workflows/ci.yml` (Gitea Actions) runs `build`, `valgrind`, `test` and `audit` on every push. `build`/`valgrind` run on any Docker-mode runner; `test`/`audit` need a self-hosted host-mode runner (real namespaces, cgroup v2 delegation, the AppArmor profile), documented with its one-time setup in the new [docs/CI.md](docs/CI.md). Every step wraps its own `timeout`, since Gitea Actions parses but does not enforce `timeout-minutes`/`concurrency` (checked against the 1.25 docs).
|
||||
- Fixed, from [review2.md](review2.md): `parseSize` (`src/util.cpp`) overflowed silently on an oversized input (`--size 16777217t` was accepted as exactly `1.0T` instead of rejected); now checked before the shift and rejected. Tracked as gap G9 in [RESEARCH](docs/RESEARCH.md); regression case added to `tests/run.sh`.
|
||||
- Fixed, from [review2.md](review2.md): `newuidmap`, `newgidmap`, `slirp4netns` and `pasta` were each pinned to one hardcoded `/usr/bin/...` path; they now go through `resolveBinary()` (state-dir override, then a fixed list of system directories), the same resolution `curl`/`tar`/`sha256sum`/`gpg` already used. Tracked as gap G10.
|
||||
- Docs: the `setns`-before-hardening ordering invariant in `Sandbox::join` and `Sandbox::start` (seccomp denies `setns`, and a filter is inherited for life) is now a comment at both call sites, not only in reviewer heads. The hand-rolled Alpine/Debian catalog parsers are logged as an accepted trade-off, gap G11, rather than left undocumented.
|
||||
- Added `LICENSE` (MIT).
|
||||
- Docs: README's two overlapping, partly stale intro blocks (a leftover `currently 1.1.3` version line, a narrower duplicate usage example) collapsed into one accurate one.
|
||||
|
||||
## 1.3.1 - 2026-10-24
|
||||
- Docs: gap G8 (`fsutil::mkdirs` following a planted symlink, [RESEARCH](docs/RESEARCH.md)) marked fixed, re-measured with `make -C audit/CVE-2021-30465 test` (PASS); removed from [COMPARISON](COMPARISON.md)'s gaps list. The fix itself shipped in 1.3.0 as part of the security-review hardening; this corrects the docs to match.
|
||||
|
||||
## 1.3.0 - 2026-10-24
|
||||
- Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`.
|
||||
- Compat-directory mounts pin the parent through an `O_PATH` fd so a symlink swap between check and use cannot happen (TOCTOU); symlinks whose target cannot be pinned are not recreated, fail closed instead.
|
||||
- `--i-understand` is now required for `--hardening relaxed` and `--net host`; reduced sandboxing is also reported loudly at attach time.
|
||||
- Tests: the e2e suite covers all of the above (`passed=88 failed=0`). Docs: [RESEARCH](docs/RESEARCH.md), [SPEC](SPEC.md), [README](README.md) updated in the same change.
|
||||
|
||||
## 1.2.1 - 2026-10-23
|
||||
- `scripts/release.sh`: commit trailer model name corrected (no functional change).
|
||||
|
||||
## 1.2.0 - 2026-10-23
|
||||
- `audit/`: 10 more named, sourced CVEs (18 total): the overlayfs-in-unprivileged-userns family (`CVE-2021-3493`, `CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`, directly doos's own mount on every attach), `CVE-2022-0492` (cgroup v1 release_agent; doos mounts no `/sys`), `CVE-2022-27650` (inheritable capabilities at container start), `CVE-2019-16884` and `CVE-2023-28642` (runc's `/proc` mount-target handling, tested against doos's own `/proc` mount), `CVE-2018-12020` (GnuPG SigSpoof, static check of the exact `gpg` invocation in `src/image.cpp`), `CVE-2020-7039` (libslirp memory-safety cluster, version check against `slirp4netns --version`).
|
||||
|
||||
## 1.1.5 - 2026-10-23
|
||||
- `audit/`: 8 named, sourced CVEs relevant to doos's own mechanisms (overlay writes, tar extraction, mount/directory creation, net namespace sharing, the mount-family seccomp denylist, kernel user-namespace exposure), each with its own `README.md`, `Makefile` and non-destructive `reproduce.sh` against `build/doos`. `make audit` runs them concurrently (`-j8`, recursive make); `make -C audit list|report` also available. Kernel memory-corruption CVEs (Dirty Pipe, Dirty COW, `legacy_parse_param`) are checked by kernel version only, no exploit code.
|
||||
- Found by the audit and disclosed: `fsutil::mkdirs` follows a symlink planted at a container's path, so a symlink under `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity: needs write access to the caller's own `$DOOS_HOME`). Tracked as gap G8 in [RESEARCH](docs/RESEARCH.md) and in [COMPARISON](COMPARISON.md); not yet fixed.
|
||||
- Docs: `scripts/check-docs.sh` excludes `audit/*.md` (a separate, self-contained doc set); `README.md` and `AGENTS.md` link to `audit/README.md`.
|
||||
|
||||
## 1.1.4 - 2026-10-23
|
||||
- Docs: [COMPARISON](COMPARISON.md) section 6.1 answers whether doos is a security-wise Docker replacement (same class as rootless Podman, not for untrusted code), summarized in the README safety summary.
|
||||
|
||||
+3
-2
@@ -9,7 +9,8 @@ if(NOT CMAKE_BUILD_TYPE)
|
||||
endif()
|
||||
|
||||
add_executable(doos
|
||||
src/main.cpp src/hardening.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp)
|
||||
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic)
|
||||
src/main.cpp src/hardening.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp src/davmount.cpp)
|
||||
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -O2)
|
||||
target_link_options(doos PRIVATE -Wl,-z,relro -Wl,-z,now)
|
||||
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
|
||||
install(TARGETS doos RUNTIME DESTINATION bin)
|
||||
|
||||
+22
-22
@@ -1,6 +1,6 @@
|
||||
# doos compared with Docker, Podman and others
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||
|
||||
This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is:
|
||||
|
||||
@@ -22,7 +22,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
|
||||
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
|
||||
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
|
||||
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
|
||||
| Host dirs into the container (`-v`) | **no** [D] | yes [K] | yes | yes |
|
||||
| Host dirs into the container (`-v`) | yes, `--mount SRC:DST[:ro]`, not subject to the size cap; a `dav(s)://`/`http(s)://` source auto-mounts WebDAV via a managed `rclone` [D] | yes [K] | yes | yes |
|
||||
| Published ports | none for `slirp`; automatic for `pasta` [D] | `-p` [K] | `-p` | `-p` [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||
@@ -30,7 +30,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
|
||||
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
|
||||
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
|
||||
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
|
||||
| Binary size | <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] |
|
||||
| Binary size | <!--bench:doos_size-->688 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->4,413<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] |
|
||||
| Platforms | Linux only, tested on x86_64 only [D] | Linux, macOS, Windows (via a VM) [K] | Linux | Linux; macOS and Windows via a VM [K] |
|
||||
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
|
||||
|
||||
@@ -48,24 +48,24 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
|
||||
|
||||
## 3. Measurements
|
||||
|
||||
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on <!--bench:bench_date-->2026-10-23<!--/bench-->. Versions: <!--bench:versions-->doos 1.1.2, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
|
||||
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on <!--bench:bench_date-->2026-10-25<!--/bench-->. Versions: <!--bench:versions-->doos 1.5.0, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
|
||||
|
||||

|
||||
|
||||
<!-- bench:startup:begin -->
|
||||
| Setup | Network | Time per run |
|
||||
|---|---|---|
|
||||
| bubblewrap (host /usr) | none | 8.4 ms |
|
||||
| doos Alpine image | none | 13.8 ms |
|
||||
| doos host mode | none | 18.7 ms |
|
||||
| Podman Alpine | none | 208 ms |
|
||||
| Docker Alpine (rootful) | none | 344 ms |
|
||||
| bubblewrap (host /usr) | default | 7.4 ms |
|
||||
| doos Alpine image | default | 52.1 ms |
|
||||
| doos host mode | default | 56.9 ms |
|
||||
| doos Alpine, pasta | default | 64.6 ms |
|
||||
| Podman Alpine | default | 241 ms |
|
||||
| Docker Alpine (rootful) | default | 436 ms |
|
||||
| bubblewrap (host /usr) | none | 9.2 ms |
|
||||
| doos Alpine image | none | 14.7 ms |
|
||||
| doos host mode | none | 16.2 ms |
|
||||
| Podman Alpine | none | 216 ms |
|
||||
| Docker Alpine (rootful) | none | 346 ms |
|
||||
| bubblewrap (host /usr) | default | 8 ms |
|
||||
| doos Alpine image | default | 56.2 ms |
|
||||
| doos host mode | default | 60 ms |
|
||||
| doos Alpine, pasta | default | 66 ms |
|
||||
| Podman Alpine | default | 233 ms |
|
||||
| Docker Alpine (rootful) | default | 449 ms |
|
||||
<!-- bench:startup:end -->
|
||||
|
||||
How to read this honestly:
|
||||
@@ -82,7 +82,7 @@ How to read this honestly:
|
||||
|---|---|
|
||||
| bubblewrap | 79 KiB (80,424 bytes) |
|
||||
| crun | 472 KiB (483,392 bytes) |
|
||||
| doos | 552 KiB (565,680 bytes) |
|
||||
| doos | 688 KiB (704,512 bytes) |
|
||||
| docker CLI | 29.9 MiB (31,374,336 bytes) |
|
||||
| podman | 41.2 MiB (43,180,896 bytes) |
|
||||
<!-- bench:sizes:end -->
|
||||
@@ -105,8 +105,8 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
|
||||
**Where doos is better**
|
||||
|
||||
- No daemon and no root. With Docker's default install, anyone who can talk to the daemon controls the machine [S]. doos has no such trust boundary: it can only do what the invoking user can do, plus what the `newuidmap` helper allows.
|
||||
- Tiny and auditable: <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
|
||||
- Instant: <!--bench:doos_range-->14 to 65 ms<!--/bench--> against <!--bench:docker_range-->344 to 436 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->208 to 241 ms<!--/bench--> for Podman [M].
|
||||
- Tiny and auditable: <!--bench:doos_size-->688 KiB<!--/bench-->, <!--bench:src_lines-->4,413<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
|
||||
- Instant: <!--bench:doos_range-->15 to 66 ms<!--/bench--> against <!--bench:docker_range-->346 to 449 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->216 to 233 ms<!--/bench--> for Podman [M].
|
||||
- Safe defaults a user does not have to remember: Docker's default capability set, a seccomp filter, an allowlist of host `/etc`, a memory and pid cap, a written-data cap [D][M].
|
||||
- A "host mode" that reuses the host's installed tools (compilers, `apt` state) for throwaway shells with no image download [D].
|
||||
- A size cap that freezes instead of filling the disk [D][M]. Docker's writable-layer size limit needs the overlay2 driver on XFS with project quotas [S: [Fabian Lee](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)].
|
||||
@@ -114,7 +114,7 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
|
||||
**Where Docker is better**
|
||||
|
||||
- Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry.
|
||||
- Host directories and devices into the container (`-v`, `--device`, GPUs). doos has no bind-mount option [D].
|
||||
- Devices and GPUs (`--device`, `--gpus`); doos has neither [D]. Host directories do bind-mount (`--mount SRC:DST[:ro]`), and so does remote storage: `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source auto-installs and drives `rclone` to mount a WebDAV share, something Docker itself has no built-in equivalent for either [D].
|
||||
- Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D].
|
||||
- Networking: user-defined networks, DNS between containers, published ports on any interface [K].
|
||||
- Compose, Swarm and the Kubernetes ecosystem [K].
|
||||
@@ -166,7 +166,7 @@ Short answer: it is in the same class as rootless Podman and rootless Docker by
|
||||
|
||||
Use doos for your own tools, builds and experiments, where you accept the risk. For code you do not trust, or for several tenants, use rootless Podman with its default seccomp and SELinux or AppArmor, and gVisor or Kata when the kernel must not be shared (see the conclusion table in section 8). Functionally it is also not a drop-in replacement: see section 7.
|
||||
|
||||
Full list of what doos does not cover is at the end of [RESEARCH](docs/RESEARCH.md); the exact hardening is in [SPEC](SPEC.md) under Hardening.
|
||||
Full list of what doos does not cover is at the end of [RESEARCH](docs/RESEARCH.md); the exact hardening is in [SPEC](SPEC.md) under Hardening. Named CVE regression checks against `build/doos` live in [audit/README.md](audit/README.md) (`make audit`).
|
||||
|
||||
## 7. What doos lacks, and which code would change
|
||||
|
||||
@@ -175,7 +175,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
|
||||
| Gap | Where |
|
||||
|---|---|
|
||||
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) |
|
||||
| No host directory bind mounts, no devices, no GPU | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
|
||||
| No devices, no GPU passthrough (host directory bind mounts exist: `--mount`) | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
|
||||
| No background containers, `stop`, `exec` by name, logs | lifetime model in `Sandbox::start` / `Sandbox::watch` (container lives with its first command) |
|
||||
| No user-defined networks, no `-p` | `src/network.cpp` |
|
||||
| No Compose or Kubernetes integration | not present |
|
||||
@@ -186,7 +186,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
|
||||
|
||||
## 8. Conclusion
|
||||
|
||||
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->552 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->14 to 65 ms<!--/bench--> [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
|
||||
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->688 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->15 to 66 ms<!--/bench--> [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
|
||||
|
||||
Choose by the job:
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 retoor <retoor@molodetz.nl>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -4,7 +4,7 @@
|
||||
BUILD ?= build
|
||||
DOOS := $(BUILD)/doos
|
||||
|
||||
.PHONY: help build test check-docs bench charts docs release clean
|
||||
.PHONY: help build test audit valgrind check-docs bench charts docs release clean
|
||||
|
||||
help: ## list the targets
|
||||
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | column -t -s "$$(printf '\t')"
|
||||
@@ -16,6 +16,12 @@ build: ## configure and compile into build/
|
||||
test: build ## end-to-end suite (real downloads, needs network, pasta, gpg, cgroup v2)
|
||||
tests/run.sh $(DOOS)
|
||||
|
||||
audit: build ## run the CVE regression checks under audit/ against build/doos (concurrent, -j)
|
||||
$(MAKE) -C audit -j8 test DOOS=$(abspath $(DOOS))
|
||||
|
||||
valgrind: build ## memcheck against a fixed set of doos invocations (needs valgrind, see valgrind/README.md)
|
||||
scripts/valgrind.sh $(abspath $(DOOS))
|
||||
|
||||
check-docs: ## every document links to every other, every relative link resolves
|
||||
scripts/check-docs.sh
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# doos
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||
|
||||
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime.
|
||||
|
||||
@@ -10,23 +10,15 @@ doos get_or_create [OPTIONS] NAME
|
||||
doos attach [OPTIONS] NAME [-- CMD...]
|
||||
doos delete [--yes] NAME
|
||||
doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
|
||||
doos davmount list | umount [--yes] ID
|
||||
doos apparmor # profile text for this binary (Ubuntu 24.04+)
|
||||
```
|
||||
|
||||
Version: see `VERSION` (currently 1.1.3). Release history: `CHANGELOG.md`.
|
||||
|
||||
User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime.
|
||||
|
||||
```
|
||||
doos list
|
||||
doos get_or_create [--net slirp|host|none] NAME
|
||||
doos attach [--net slirp|host|none] NAME [-- CMD...]
|
||||
doos delete [--yes] NAME
|
||||
```
|
||||
Version: see `VERSION`. Release history: [CHANGELOG.md](CHANGELOG.md). License: [MIT](LICENSE).
|
||||
|
||||
Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`.
|
||||
|
||||
Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the
|
||||
Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images), `unzip` and `fusermount3` (or `fusermount`; only for `--mount`/`--davmount` against a `dav://`/`davs://`/`http(s)://` source). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the
|
||||
binary. Generate a profile for the exact path doos is installed at, once:
|
||||
|
||||
```
|
||||
@@ -118,13 +110,31 @@ Notes, all measured:
|
||||
| `--net M` | `slirp` (outbound only, host loopback blocked), `pasta` (host-like, private sockets), `host`, `none` | slirp | fixed |
|
||||
| `--ids X` | `single` or `subuid` | subuid if possible | fixed |
|
||||
| `--hardening H` | `default` or `relaxed` | default | fixed |
|
||||
| `--i-understand` | required on every call that sets or uses `--hardening relaxed` or `--net host` | absent | fixed |
|
||||
| `--size S` | writable data cap | 4g | changeable |
|
||||
| `--memory S` | memory cap | 8g | changeable |
|
||||
| `--pids N` | process/thread cap | 4096 | changeable |
|
||||
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
|
||||
| `--mount SRC:DST[:ro]` | bind-mount host dir `SRC` at `DST` inside, repeatable (max 8) | none | changeable |
|
||||
| `--mount URL DST[:ro]` / `--davmount URL DST[:ro]` | auto-mount a WebDAV `URL` (`dav://`, `davs://`, `http://`, `https://`) at `DST`, see below | none | changeable |
|
||||
| `--dav-user USER` / `--dav-pass PASS` | credentials for the `--mount`/`--davmount` entry just given | none | changeable |
|
||||
| `--dav-vendor NAME` | `other`\|`nextcloud`\|`owncloud`\|`sharepoint`\|`sharepoint-ntlm` for that entry | other | changeable |
|
||||
| `--dav-insecure` | skip TLS certificate verification for that entry | off | changeable |
|
||||
|
||||
Changeable options apply the next time the container starts. Examples: `doos attach --memory 2g --cpus 1 build`, `doos attach --net pasta --image debian-bookworm web`.
|
||||
|
||||
`--mount` gives a container extra storage without counting against `--size`. For a plain host directory: `doos attach --mount /some/host/dir:/data NAME` (`SRC` must exist and be a directory when the container starts; add `:ro` for read-only).
|
||||
|
||||
For remote storage, give a WebDAV URL instead of a host path and doos does the rest: it downloads, verifies and installs `rclone` once into its own managed directory under `$DOOS_HOME` (never the system, never your `PATH`), then drives it to mount the share on the host before the container starts:
|
||||
|
||||
```
|
||||
doos attach --mount davs://user:pass@cloud.example.com/remote.php/webdav /data NAME
|
||||
# or, keeping the password out of the URL and shell history:
|
||||
doos attach --davmount https://cloud.example.com/remote.php/webdav /data --dav-user user --dav-pass pass --dav-vendor nextcloud NAME
|
||||
```
|
||||
|
||||
`dav://` and `davs://` are shorthand for plain-http and https WebDAV; a bare `http://`/`https://` URL works the same way. The mount is deduplicated by target (URL + credentials + vendor) and reused across containers and attaches instead of remounting every time; it lives on the host, outside any container, and keeps running after the container exits (writes go through a short write-back cache, about a second, before they reach the server). See `doos davmount list` and `doos davmount umount [--yes] ID` to inspect or tear one down, and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how it is verified and supervised.
|
||||
|
||||
## Limits, size cap and hardening
|
||||
|
||||
- **Limits** (`--memory`, `--pids`, `--cpus`): cgroup v2, measured here: a 256m container touching 400 MB is killed whole and doos says so; 80 forks against `--pids 32` fail and are reported; `--cpus 0.5` held 4 busy loops to 2.3 CPU-seconds in 4.3 s of wall time.
|
||||
@@ -158,6 +168,11 @@ Changeable options apply the next time the container starts. Examples: `doos att
|
||||
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
|
||||
| `container 'x' is running` on delete | Exit the attached command first. |
|
||||
| `container limit reached (64)` | Delete one first. |
|
||||
| `davmount failed for URL: authentication failed` | Wrong `--dav-user`/`--dav-pass` (or credentials embedded in the URL); fix and retry. |
|
||||
| `davmount failed for URL: cannot reach the server` | Check the URL, the server, and network/DNS from the host. |
|
||||
| `davmount failed for URL: TLS certificate error` | Self-signed server: pass `--dav-insecure` if you trust it. |
|
||||
| `davmount failed for URL: the server does not look like a WebDAV endpoint` | Wrong path or `--dav-vendor`; check the URL against the server's WebDAV docs. |
|
||||
| `davmount needs 'unzip' installed` | Install `unzip` (only needed the first time rclone is installed). |
|
||||
|
||||
## Safety summary
|
||||
|
||||
@@ -175,7 +190,7 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
|
||||
|
||||
## Development
|
||||
|
||||
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`.
|
||||
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`. CI (`.gitea/workflows/ci.yml`) runs `build`, `valgrind`, `test` and `audit` on every push; see [docs/CI.md](docs/CI.md) for what each needs and the one-time runner setup.
|
||||
|
||||
## Project docs
|
||||
|
||||
@@ -187,5 +202,8 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
|
||||
| [`COMPARISON.md`](COMPARISON.md) | doos next to Docker, Podman, bubblewrap, nspawn, LXC/Incus, Apptainer, Distrobox, gVisor/Kata, with pros, cons, measurements and a conclusion |
|
||||
| [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow |
|
||||
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
||||
| [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) |
|
||||
| [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) |
|
||||
| [`docs/CI.md`](docs/CI.md) | Gitea Actions: what runs where, runner setup |
|
||||
|
||||
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# doos specification
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||
|
||||
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md).
|
||||
|
||||
@@ -16,6 +16,8 @@ Authoritative description of behavior and limits. Code constants live in `src/sp
|
||||
| `doos image add [--sha256 HEX] SOURCE [NAME]` | Installs an image. See Images. |
|
||||
| `doos apparmor` | Prints an AppArmor profile (named after the binary's path) that lets this binary use user namespaces. A start that fails with EPERM/EACCES while `apparmor_restrict_unprivileged_userns=1` prints the command to install it. |
|
||||
| `doos image delete [--yes] NAME` | Dry run unless `--yes`. Refuses an image used by any container, even with `--yes`. |
|
||||
| `doos davmount list` | Table of davmount targets this host currently manages: ID, STATE (mounted/stale), VENDOR, URL. |
|
||||
| `doos davmount umount [--yes] ID` | Dry run unless `--yes`. Unmounts and removes that managed target; stops storage for any container still using it. |
|
||||
|
||||
`get-or-create` is accepted as an alias.
|
||||
|
||||
@@ -26,13 +28,29 @@ Container options (accepted by `get_or_create` and `attach`):
|
||||
| `--image NAME` | Start from an image instead of the host system. Missing catalog images are fetched on demand. | none (host mode) | no |
|
||||
| `--net slirp\|pasta\|host\|none` | Network mode, see Networking. | slirp | no |
|
||||
| `--ids single\|subuid` | Id mapping. | subuid when available, else single | no |
|
||||
| `--hardening default\|relaxed` | `relaxed` = no capability drop, no seccomp filter, full readable host `/etc` (host mode). | default | no |
|
||||
| `--hardening default\|relaxed` | `relaxed` = no capability drop, no seccomp filter, full readable host `/etc` (host mode). `relaxed` and `--net host` also require `--i-understand` on every `get_or_create` and `attach` call, and print a warning once given. | default | no |
|
||||
| `--i-understand` | Confirmation flag (no value) for the reduced sandboxing described above. Without it, `--hardening relaxed` or `--net host` is an error. | absent | n/a |
|
||||
| `--size SIZE` | Cap for what the container writes (`512m`, `8g`, `1t`; k/m/g/t). | 4g | yes |
|
||||
| `--memory SIZE` | Memory cap (cgroup `memory.max`, swap 0, whole-container OOM kill). | 8g | yes |
|
||||
| `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
|
||||
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
|
||||
| `--mount SRC:DST[:ro]` | Bind-mount host directory `SRC` at `DST` inside the container, repeatable (max 8). Not subject to `--size`: it is host storage, not the container's writable layer. Replaces the whole mount list when given. | none | yes |
|
||||
| `--mount URL DST[:ro]` / `--davmount URL DST[:ro]` | `URL` starting `dav://`, `davs://`, `http://` or `https://`: davmount, see below. Same list and cap as `--mount`. | none | yes |
|
||||
| `--dav-user USER`, `--dav-pass PASS` | Credentials for the davmount entry just given (overrides any `user[:pass]@` in the URL). | none | yes |
|
||||
| `--dav-vendor NAME` | `other`, `nextcloud`, `owncloud`, `sharepoint` or `sharepoint-ntlm` for that entry. | other | yes |
|
||||
| `--dav-insecure` | Skip TLS certificate verification for that entry. | off | yes |
|
||||
|
||||
Changeable options apply at the next start of the container. Naming a fixed option on an existing container with a different value is an error, never a silent change.
|
||||
Changeable options apply at the next start of the container. Naming a fixed option on an existing container with a different value is an error, never a silent change. A plain `--mount`'s `SRC` must exist and be a directory at the time the container starts; a missing `SRC` fails the start loudly. A davmount's URL is resolved and mounted automatically, see below.
|
||||
|
||||
## davmount
|
||||
|
||||
`--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source turns into a managed WebDAV mount instead of a plain bind: doos downloads, verifies and installs a pinned `rclone` build under `$DOOS_HOME/tools/rclone-VERSION/rclone` (never the system, never the caller's `PATH`; same trust model as the catalog images: a fixed version, fetched over https, checked against a GPG-signed `SHA256SUMS` and a key pinned by fingerprint), then drives it to mount the target on the host, before the container's namespaces are created, in `Sandbox::start`.
|
||||
|
||||
- **Identity and reuse**: a target is identified by URL + user + pass + vendor + the insecure flag (not by destination), hashed into a 16-hex-char id under `$DOOS_HOME/davmounts/<id>/{mnt,log,pid,meta,lock}`. The same target reused by another `--mount`/`--davmount` entry, in the same or a different container, is mounted once and reused; a dead or unreachable mount is detected (process liveness plus an active directory listing, since a FUSE mount answers `stat` on its root without ever reaching the backend) and remounted automatically.
|
||||
- **On-the-fly, no config file**: rclone's connection-string syntax (`:webdav,url=...,vendor=...,user=...,pass=...:`), every field quoted; the password is passed through `rclone obscure` first (rclone's own documented requirement), never in plain text to the process, and never on its command line in a form `ps` would show in full (quoted into one argv element doos builds itself).
|
||||
- **Lifecycle**: the mount runs as a detached, double-forked process (`setsid`, reparented to init) so it outlives the `doos attach` that created it; writes go through rclone's VFS write-back cache (`--vfs-cache-mode=writes`, required for WebDAV, which cannot stream; `--vfs-write-back=1s`, shortened from rclone's 5 s default) and reach the server shortly after a file is closed, independent of whether the container that wrote it has already exited.
|
||||
- **Readiness and failure**: a fresh mount is given up to 20 s to answer a real directory listing (not just `stat`, which a FUSE root answers without a backend round trip); failure is classified from the log into one of: authentication, unreachable server, TLS certificate, not a WebDAV endpoint, or a generic fallback naming the log file. A failed attempt is unmounted but its log and directory are left for `doos davmount list`/`umount` to inspect or clear.
|
||||
- **Not covered**: doos never implements WebDAV itself, and does not audit rclone's own implementation (see `audit/CVE-2020-7039` for the equivalent libslirp caveat on the networking side); a self-signed or misconfigured TLS endpoint needs `--dav-insecure` (an explicit, logged choice, not a default).
|
||||
|
||||
## Limits (hard caps)
|
||||
|
||||
@@ -58,6 +76,10 @@ Changeable options apply at the next start of the container. Naming a fixed opti
|
||||
| slirp4netns ready wait | 5 s |
|
||||
| Lock wait | 5 s |
|
||||
| Tree recursion depth | 256 |
|
||||
| `--mount`/`--davmount` entries | 8 per container |
|
||||
| davmount targets managed at once | 32 |
|
||||
| davmount ready wait | 20 s |
|
||||
| rclone download | 128 MiB |
|
||||
|
||||
## State layout
|
||||
|
||||
@@ -66,9 +88,14 @@ Changeable options apply at the next start of the container. Naming a fixed opti
|
||||
```
|
||||
containers/NAME/{meta,pid,lock,seeded.*,rootfs/,layers/<dir>/{upper,work},private/{root,var,etc}}
|
||||
images/NAME/{meta,rootfs/}
|
||||
tools/rclone-VERSION/rclone
|
||||
davmounts/ID/{mnt/,log,pid,meta,lock}
|
||||
```
|
||||
|
||||
`meta` keys: `net created version image ids size`.
|
||||
Container `meta` keys: `net created version image ids size memory pids cpus hardening`, then `mounts.count` and,
|
||||
per entry, `mounts.N.kind mounts.N.src mounts.N.dst mounts.N.ro` (bind), plus `mounts.N.user mounts.N.pass
|
||||
mounts.N.vendor mounts.N.insecure` (webdav; `pass` is plain text, see Safety rules). A container created under
|
||||
1.5.0's single-line `mounts=` format is still read, bind entries only.
|
||||
|
||||
## Container filesystem, host mode (no `--image`)
|
||||
|
||||
@@ -134,3 +161,5 @@ User, mount, pid, uts, ipc namespaces always; network namespace unless `host`. I
|
||||
- Unverifiable input is rejected: URL images without sha256, hash mismatches, bad names, bad state paths, missing slirp4netns or newuidmap when requested.
|
||||
- Seeding and scans report what they skipped (`unreadable skipped: N`).
|
||||
- A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind.
|
||||
- `--dav-pass` (or a password embedded in a davmount URL) is stored in plain text in the container's `meta` file (0700 directory, owner-only, same trust level as everything else under `$DOOS_HOME`); it is never written to a log, never passed to `rclone` on its command line, and is run through `rclone obscure` only immediately before the mount process execs.
|
||||
- `doos davmount umount` is dry-run by default, scoped to `$DOOS_HOME/davmounts`, never follows symlinks; unmounting a target in use stops storage for any container still mounting it (not prevented, only named in the dry-run output).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2015-1328: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# CVE-2015-1328: overlayfs missing permission check on file creation in the upper directory (unprivileged userns)
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Exploited in the wild (CISA KEV). Fixed: Ubuntu 14.10 3.16.0-41.55, 15.04 3.19.0-21.21, 14.04 3.13.0-55.92.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2015-1328)
|
||||
- [oss-security disclosure thread](https://openwall.com/lists/oss-security/2015/06/16/7)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
The oldest of the overlayfs-in-userns bugs in this audit: overlayfs did not check
|
||||
permissions when creating files in the upper directory, reachable from CONFIG_USER_NS=y
|
||||
kernels where overlayfs carries FS_USERNS_MOUNT, the default on the Ubuntu releases of
|
||||
that era and the same precondition doos depends on today. Kept in the list to complete
|
||||
the history of this bug class, not because current kernels are expected to be exposed.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version check against the three listed fixed builds; on any host from the last
|
||||
several years this is expected to PASS trivially, included to complete the category.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same distro-backport blind spot as the newer overlayfs entries: a version string alone
|
||||
cannot prove a specific vendor backport is present.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2015-1328: kernel version check against the three listed fixed builds. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
major=$(echo "$full" | grep -oE '^[0-9]+')
|
||||
[ -n "$major" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
if [ "$major" -ge 4 ]; then
|
||||
pass "kernel $full, major version 4 or newer, long past this 3.x-era fix"
|
||||
fi
|
||||
skip "kernel $full is on the 3.x branch this bug targeted; this check cannot tell a patched 3.13.0-55.92/3.16.0-41.55/3.19.0-21.21 point release from an unpatched one, check the Ubuntu advisory for the exact build"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2016-5195: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,35 @@
|
||||
# CVE-2016-5195: "Dirty COW": race condition in copy-on-write handling of private read-only mappings
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.0-7.2. Fixed in Linux 4.8.3 (and backported to every maintained branch since).
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2016-5195)
|
||||
- [NVD entry](https://nvd.nist.gov/vuln/detail/CVE-2016-5195)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A race in the memory manager's copy-on-write handling let a local user write to memory
|
||||
that should have been read-only, including on-disk binaries mapped read-only. Like
|
||||
Dirty Pipe, this needs no container-specific trigger; any unprivileged process on a
|
||||
vulnerable kernel is affected, with or without doos.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version only. Fixed in 4.8.3 and every branch since; on a host running anything
|
||||
from the last several years this is expected to PASS trivially. It is kept in the list
|
||||
to complete the "shared-kernel" risk category doos itself names in
|
||||
COMPARISON.md section 6, not because it is expected to ever FAIL on a maintained host.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same version-check limits as CVE-2022-0847: distribution kernels sometimes backport
|
||||
fixes without a version bump this check recognizes; it reports the implication of the
|
||||
reported version, not a scan of the kernel's actual code.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2016-5195 (Dirty COW): kernel version against the fixed release. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
ver=$(uname -r | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
if [ "$major" -gt 4 ] 2>/dev/null; then
|
||||
pass "kernel $ver, major version past 4.x, long past the Dirty COW fix (4.8.3)"
|
||||
fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -gt 8 ]; then
|
||||
pass "kernel $ver is past 4.8, past the Dirty COW fix"
|
||||
fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -eq 8 ] && [ "$patch" -ge 3 ]; then
|
||||
pass "kernel $ver is at or past the fixed 4.8.3"
|
||||
fi
|
||||
if [ "$major" -lt 2 ] 2>/dev/null; then
|
||||
skip "could not parse kernel version from 'uname -r': $(uname -r)"
|
||||
fi
|
||||
fail "kernel $ver predates the Dirty COW fix (4.8.3); this bug has existed since 2.6.22"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2018-12020: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,41 @@
|
||||
# CVE-2018-12020: GnuPG "SigSpoof": signature/decryption status spoofed via unescaped filename on --status-fd 2
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Disclosed June 2018. Fixed in GnuPG 2.2.8 / 1.4.23.
|
||||
|
||||
## Source
|
||||
|
||||
- [Help Net Security writeup](https://www.helpnetsecurity.com/2018/06/15/cve-2018-12020-digital-signature-spoofing/)
|
||||
- [CERT-EU advisory](https://cert.europa.eu/publications/security-advisories/2018-016/markdown)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
Applications that ran GnuPG with `--status-fd 2` (merging machine-readable status lines
|
||||
into stderr) and GnuPG's verbose mode (which echoes the input file's embedded name to
|
||||
stderr without escaping newlines) could be fed a crafted message whose filename field
|
||||
injected fake status lines, spoofing a GOODSIG result for an unsigned or badly signed
|
||||
input. doos calls `gpg` to verify Alpine's and Ubuntu's release signatures
|
||||
(`verifySignature` in `src/image.cpp`), so getting this invocation right is the one place
|
||||
a bug in this class would matter: a spoofed GOODSIG here would make doos trust and
|
||||
extract an unverified image.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Reads the exact `gpg` invocations in `src/image.cpp` for the two properties that defeat
|
||||
this bug class: `--status-fd` must be a dedicated fd (not `2`, which would merge it with
|
||||
stderr), and `--verbose` must not be passed. Source inspection, not a dynamic exploit
|
||||
attempt: crafting a message that reproduces GnuPG's own newline-escaping bug would be
|
||||
testing GnuPG, not doos, and depends on an unpatched GnuPG the audit does not control.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A static check: it verifies the invocation in the source tree the check runs against,
|
||||
not the behavior of whatever `gpg` binary happens to be installed. It also does not
|
||||
prove there is no other way to smuggle data into the fd doos does read; it confirms the
|
||||
one specific, named defeat (`--status-fd 2` plus `--verbose`) is not present.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2018-12020 (SigSpoof): src/image.cpp's gpg invocations must not combine --status-fd 2
|
||||
# (merged with stderr) with --verbose (which echoes the unescaped filename to stderr).
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/../.." && pwd)/src/image.cpp"
|
||||
[ -f "$SRC" ] || skip "src/image.cpp not found at $SRC"
|
||||
|
||||
if grep -E 'gpg' "$SRC" | grep -qE -- '--status-fd["'"'"']?,?\s*["'"'"']?2\b'; then
|
||||
fail "$SRC passes --status-fd 2 to gpg (merges status lines with stderr, the precondition for SigSpoof)"
|
||||
fi
|
||||
if grep -E 'gpg' "$SRC" | grep -q -- '--verbose'; then
|
||||
fail "$SRC passes --verbose to gpg (echoes the unescaped input filename to stderr)"
|
||||
fi
|
||||
pass "$SRC does not combine --status-fd 2 with --verbose in any gpg invocation"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2019-16884: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,39 @@
|
||||
# CVE-2019-16884: runc AppArmor confinement weakened by incorrect mount-target validation for /proc
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 6.5-7.5. Fixed in runc 1.0.0-rc9.
|
||||
|
||||
## Source
|
||||
|
||||
- [Rapid7 vulnerability record](https://www.rapid7.com/db/vulnerabilities/redhat-openshift-cve-2019-16884)
|
||||
- [Debian security tracker](https://security-tracker.debian.org/tracker/CVE-2019-16884)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc's root filesystem setup validated a mount target incorrectly, letting a crafted
|
||||
image mount something over `/proc` in a way that weakened the AppArmor profile runc
|
||||
otherwise applies. doos has no AppArmor profile that constrains the container's own
|
||||
filesystem view the way `docker-default` does (COMPARISON.md section 6 already states
|
||||
this as a design difference, not a bug), so the specific confinement this CVE weakens
|
||||
does not exist in doos to begin with. What is still worth checking directly is the thing
|
||||
runc's bug actually let an attacker do: substitute a non-`proc` mount at the container's
|
||||
`/proc`.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and checks, from inside, that `/proc`'s filesystem type (via
|
||||
`/proc/self/mountinfo` on the real `/proc` doos mounted, read before the check's own
|
||||
command could remount it) is `proc`, matching `doMount("proc", ..., "proc", ...)` in
|
||||
`src/sandbox.cpp`.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Confirms doos's own `/proc` mount is what it claims to be at container start; it does
|
||||
not attempt the image-controlled mount-target substitution runc's bug needed, since doos
|
||||
has no AppArmor profile for that substitution to bypass in the first place.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2019-16884-class: doos's own /proc mount must actually be of type proc.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit16884 -- sh -c '
|
||||
awk "\$5==\"/proc\" { for (i=1;i<=NF;i++) if (\$i==\"-\") { print \$(i+1); exit } }" /proc/self/mountinfo
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit16884 >/dev/null 2>&1
|
||||
|
||||
fstype=$(echo "$out" | tail -1)
|
||||
if [ "$fstype" != "proc" ]; then
|
||||
fail "/proc inside the container is not a 'proc' mount: saw '$fstype' (raw: $out)"
|
||||
fi
|
||||
pass "/proc inside the container is a genuine proc mount"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2019-5736: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CVE-2019-5736: runc container escape via /proc/self/exe overwrite
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.2. Fixed in runc 1.0.0-rc7, Docker 18.09.2.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu security notice](https://discourse.ubuntu.com/t/runc/55877)
|
||||
- [Trend Micro writeup](https://www.trendmicro.com/vinfo/be/security/news/vulnerabilities-and-exploits/cve-2019-5736-runc-container-escape-vulnerability-provides-root-access-to-the-target-machine)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc re-executes itself through `/proc/self/exe` as part of its container-start
|
||||
sequence. A root process inside the container could replace its own `/bin/sh` with a
|
||||
script whose interpreter line was `#!/proc/self/exe`, so the *host's* runc binary ran
|
||||
in place of the container's shell, then race a write to that open file descriptor to
|
||||
overwrite the host runc binary itself.
|
||||
|
||||
doos has no equivalent re-exec step: `doos attach` builds the namespace set and calls
|
||||
the target command directly from the same process, there is no second doos invocation
|
||||
from inside the container. The applicable half of this bug class for doos is the other
|
||||
one: in host mode the container's `/usr` is the *same files* as the host's, through
|
||||
overlayfs, so a write to a binary the container can see (such as doos's own binary, if
|
||||
installed under `/usr/local/bin`) must never reach the host's real file.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Builds a host-mode container, overwrites a file under the overlaid `/usr` from inside
|
||||
it (anything, not specifically doos's own binary, to keep the check independent of
|
||||
where this `doos` happens to be installed), exits, then compares the real host file's
|
||||
checksum before and after. A difference is FAIL.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This does not reproduce the runc bug's mechanism (there is no self re-exec to race);
|
||||
it tests the property that would have mattered if it did: host binaries are read-only
|
||||
from a container's point of view. Needs `sha256sum` and a host-mode attach to work.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2019-5736: host binaries must stay unwritable from inside a host-mode container.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
name() { echo "$0"; }
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
command -v sha256sum >/dev/null || skip "sha256sum not installed"
|
||||
|
||||
TARGET=/usr/bin/true
|
||||
[ -r "$TARGET" ] || skip "$TARGET not present or not readable on this host"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
before=$(sha256sum "$TARGET" | cut -d' ' -f1)
|
||||
|
||||
out=$("$DOOS" attach --net none audit5736 -- sh -c "echo clobber > '$TARGET' 2>/dev/null; echo done" 2>&1)
|
||||
rc=$?
|
||||
"$DOOS" delete --yes audit5736 >/dev/null 2>&1
|
||||
|
||||
after=$(sha256sum "$TARGET" | cut -d' ' -f1)
|
||||
|
||||
if [ "$before" != "$after" ]; then
|
||||
fail "host $TARGET changed after a container wrote to the overlaid path (before=$before after=$after)"
|
||||
fi
|
||||
pass "host $TARGET unchanged after an attempted overwrite from inside the container (doos exit=$rc)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2020-15257: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,42 @@
|
||||
# CVE-2020-15257: containerd-shim API reachable from a container sharing the host network namespace
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 5.2 (Medium). Fixed in containerd 1.3.9 / 1.4.3.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2020-15257)
|
||||
- [Tenable entry](https://www.tenable.com/cve/CVE-2020-15257)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
containerd-shim listened on an abstract Unix domain socket and only checked the caller's
|
||||
effective UID (0), not which namespace it called from. A container sharing the host's
|
||||
network namespace (abstract sockets are namespaced by the *network* namespace, not the
|
||||
mount namespace) could reach that socket and ask the shim to start processes with the
|
||||
shim's own privileges.
|
||||
|
||||
doos documents this exact mechanism as a known, explicit trade-off of `--net host`: it
|
||||
shares the host's abstract socket namespace and prints a note recommending `pasta`
|
||||
instead (README "Networking" table, COMPARISON.md section 6). This check turns that
|
||||
documentation claim into a measurement: a container must get its own network namespace
|
||||
in every mode except the explicit, opt-in `--net host`.
|
||||
|
||||
## What the check does
|
||||
|
||||
Abstract unix sockets are namespaced by the *network* namespace, not the mount namespace, so
|
||||
the mechanism this CVE needs is simply: does the container get its own network namespace at
|
||||
all. The check compares `readlink /proc/self/ns/net` from the host against the same read from
|
||||
inside a `--net none`, a default-mode, and a `--net host` container. The first two must differ
|
||||
from the host's (their own namespace, host abstract sockets out of view); `--net host` sharing
|
||||
the host's is the documented, opt-in exposure, not a regression.
|
||||
|
||||
## Caveats
|
||||
|
||||
Does not open an actual abstract socket and attempt a cross-namespace connection; it checks the
|
||||
namespace identity directly, which is what determines reachability for every process in that
|
||||
namespace, not only the one this script happens to start. `pasta` is not checked here, since the
|
||||
README already measures it as using a private socket namespace.
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2020-15257: a container must get its own network namespace (which removes the host's
|
||||
# abstract unix sockets from view) in every net mode except the explicit, documented --net host.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
host_ns=$(readlink /proc/self/ns/net 2>/dev/null) || skip "cannot read /proc/self/ns/net on this host"
|
||||
|
||||
none_ns=$("$DOOS" attach --net none auditns_none -- readlink /proc/self/ns/net 2>/dev/null | tail -1)
|
||||
"$DOOS" delete --yes auditns_none >/dev/null 2>&1
|
||||
|
||||
default_ns=$("$DOOS" attach auditns_default -- readlink /proc/self/ns/net 2>/dev/null | tail -1)
|
||||
"$DOOS" delete --yes auditns_default >/dev/null 2>&1
|
||||
|
||||
host_mode_ns=$("$DOOS" attach --net host auditns_host -- readlink /proc/self/ns/net 2>/dev/null | tail -1)
|
||||
"$DOOS" delete --yes auditns_host >/dev/null 2>&1
|
||||
|
||||
if [ -z "$none_ns" ] || [ -z "$default_ns" ] || [ -z "$host_mode_ns" ]; then
|
||||
skip "could not read a container's net namespace id (host=$host_ns none=$none_ns default=$default_ns host-mode=$host_mode_ns)"
|
||||
fi
|
||||
|
||||
if [ "$none_ns" = "$host_ns" ]; then
|
||||
fail "--net none shares the host's network namespace ($none_ns): host abstract sockets would be reachable"
|
||||
fi
|
||||
if [ "$default_ns" = "$host_ns" ]; then
|
||||
fail "the default net mode shares the host's network namespace ($default_ns): host abstract sockets would be reachable"
|
||||
fi
|
||||
if [ "$host_mode_ns" != "$host_ns" ]; then
|
||||
pass "--net host did not share the host's namespace ($host_mode_ns vs $host_ns); isolation is stronger than documented, default modes are isolated ($none_ns, $default_ns)"
|
||||
fi
|
||||
pass "default/none use a private net namespace ($none_ns, $default_ns), --net host shares the host's ($host_ns) as documented"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2020-7039: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,40 @@
|
||||
# CVE-2020-7039: libslirp/Slirp memory-safety bugs (heap overflow, use-after-free, OOB access) in the user-mode network stack
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Red Hat RHSA-2020:0889 (Important), bundling CVE-2019-14378, CVE-2019-15890, CVE-2020-7039, CVE-2020-8608; related CVE-2020-10756 and CVE-2020-29130 fixed later in libslirp.
|
||||
|
||||
## Source
|
||||
|
||||
- [Red Hat RHSA-2020:0889](https://access.redhat.com/errata/RHSA-2020:0889)
|
||||
- [SUSE/openSUSE advisory for CVE-2020-10756 and CVE-2020-29130](https://tenable.com/plugins/nessus/159219)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
slirp4netns, doos's default network mode, links libslirp to provide user-mode TCP/IP.
|
||||
Several memory-safety bugs were found and fixed in that code between 2019 and 2022: a
|
||||
heap overflow, a use-after-free, and multiple out-of-bounds accesses while parsing
|
||||
network traffic the sandboxed process generates or receives. Since slirp4netns runs as
|
||||
the invoking user's own process (not root, not setuid), a bug here is a crash or, in the
|
||||
worst case, code execution as that same user, not a privilege escalation, but it is the
|
||||
one piece of doos's default path written in C and parsing attacker-reachable network
|
||||
data, so it belongs in this audit's named list rather than being assumed fine by default.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Runs `slirp4netns --version` and reads its embedded `libslirp:` version line, compared
|
||||
against the fixed releases for this CVE cluster (4.3.1 for the 2020 OOB/UAF bugs; the
|
||||
2019 heap overflow was fixed earlier still). No exploit code: these are memory-corruption
|
||||
bugs in a dependency, not something to trigger against the host running the check.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
`slirp4netns --version` reports the libslirp version it was built against; a
|
||||
distribution that backports fixes without bumping that string would not be visible to
|
||||
this check. Covers only `slirp4netns`/libslirp; `pasta` (passt) is a separate codebase
|
||||
and is not covered by this entry.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2020-7039 (and the libslirp memory-safety cluster it shipped with): libslirp version
|
||||
# against its fixed release (4.3.1). No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
command -v slirp4netns >/dev/null || skip "slirp4netns not installed"
|
||||
out=$(slirp4netns --version 2>&1) || skip "'slirp4netns --version' failed: $out"
|
||||
|
||||
ver=$(echo "$out" | grep -oE 'libslirp: [0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
[ -n "$ver" ] || skip "could not find a libslirp version in: $out"
|
||||
|
||||
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
if [ "$major" -gt 4 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -gt 3 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||
if [ "$major" -eq 4 ] && [ "$minor" -eq 3 ] && [ "$patch" -ge 1 ]; then pass "libslirp $ver is at the fixed 4.3.1"; fi
|
||||
fail "libslirp $ver is below the fixed 4.3.1 (CVE-2020-10756, CVE-2020-29130, and this cluster)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2021-30465: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,46 @@
|
||||
# CVE-2021-30465: runc symlink-exchange race during mount setup ("symlink-exchange attack")
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 8.5. Fixed in runc 1.0.0-rc95.
|
||||
|
||||
## Source
|
||||
|
||||
- [Red Hat RHSB-2021-004](https://access.redhat.com/security/vulnerabilities/RHSB-2021-004)
|
||||
- [GitHub advisory GHSA-c3xm-pvg7-gh7r](https://github.com/opencontainers/runc/security/advisories/GHSA-c3xm-pvg7-gh7r)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc resolved a mount target, then mounted it, with a window between the two where an
|
||||
attacker sharing a volume could swap the target for a symlink, redirecting the mount
|
||||
outside the container's intended root.
|
||||
|
||||
doos builds its own container directories with `fsutil::mkdirs`, which calls
|
||||
`std::filesystem::create_directories`. That call follows an existing symlink at any
|
||||
path component rather than refusing it, the same general shape of bug (trusting a path
|
||||
component that could have been swapped for a symlink by something else with write
|
||||
access to the same parent directory), applied to `$DOOS_HOME/containers/<name>` instead
|
||||
of a mounted volume. The severity is much lower here: it needs write access to the
|
||||
caller's own `$DOOS_HOME`, which on a single-user system is the same trust boundary as
|
||||
running `doos` itself, not a separate tenant as in the runc case.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Plants a symlink at the exact path `get_or_create` would otherwise create
|
||||
(`$DOOS_HOME/containers/<name>`), pointing at a sentinel directory outside
|
||||
`$DOOS_HOME`. Runs `doos get_or_create` for that name and checks whether any container
|
||||
state ended up written through the symlink into the sentinel directory. Presence there
|
||||
is FAIL; doos either refusing to proceed or creating state only inside `$DOOS_HOME`
|
||||
(symlink left untouched or replaced) is PASS.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This is a same-user, same-`$DOOS_HOME` scenario, not a multi-tenant one: on a normal
|
||||
single-user install nothing else has write access to plant the symlink in the first
|
||||
place, which is why this is a defense-in-depth check and not a finding about a
|
||||
realistic attacker in doos's actual (single-user) deployment model.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2021-30465-class: a symlink planted at a container's intended directory path must not
|
||||
# cause doos to write container state outside $DOOS_HOME.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
SENTINEL="$TMP/sentinel-outside-home"
|
||||
mkdir -p "$DOOS_HOME" "$SENTINEL"
|
||||
|
||||
ln -s "$SENTINEL" "$DOOS_HOME/containers" 2>/dev/null || {
|
||||
mkdir -p "$DOOS_HOME"
|
||||
ln -s "$SENTINEL" "$DOOS_HOME/containers"
|
||||
}
|
||||
|
||||
"$DOOS" get_or_create audit30465 >"$TMP/out.log" 2>&1
|
||||
rc=$?
|
||||
|
||||
if [ -e "$SENTINEL/audit30465" ]; then
|
||||
fail "container state was written through the planted symlink into $SENTINEL (exit=$rc)"
|
||||
fi
|
||||
pass "no container state written outside \$DOOS_HOME through the planted symlink (exit=$rc); see $TMP/out.log"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2021-3493: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,41 @@
|
||||
# CVE-2021-3493: Ubuntu OverlayFS privilege escalation via missing user-namespace fscap check
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.8 (High). CISA KEV. Fixed in Ubuntu kernels 5.8.0-50.56 / 5.4.0-72.80 / 4.15.0-142.146 / 4.4.0-209.241.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz vulnerability database](https://www.wiz.io/vulnerability-database/cve/cve-2021-3493)
|
||||
- [CircleCI CVE record](https://cve.circl.lu/vuln/CVE-2021-3493)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
Ubuntu's kernel carries a patch allowing unprivileged overlayfs mounts, which is exactly
|
||||
what doos relies on for every attach (host mode overlays `/usr` and `/opt`, image mode
|
||||
overlays an extracted rootfs). The kernel's overlayfs did not convert file capabilities
|
||||
against the mounting user namespace before copy-up, so a crafted lower-layer file with a
|
||||
capability xattr could grant that capability once copied into the upper layer and read
|
||||
back outside the namespace, root-equivalent privileges from an ordinary unprivileged
|
||||
overlay mount. This is the single most on-point kernel bug for doos's core design: it is
|
||||
not "a container escape through some unrelated mechanism", it is the exact mount doos
|
||||
issues on every `attach`.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version against the fixed builds (branch-aware: 5.8/5.4/4.15/4.4 have published
|
||||
fixed point releases; anything on an unlisted branch between 4.4 and 5.11 is treated as
|
||||
unproven and reported, not assumed safe). No exploit code: this is a kernel capability-set
|
||||
bug, not something safe to trigger for real on the host running the check.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This is the first of three overlayfs-in-userns kernel bugs in this audit (see also
|
||||
`CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`); together they are the strongest
|
||||
argument for keeping the host kernel current, since doos's entire model depends on this
|
||||
exact combination (overlayfs + unprivileged user namespace) staying safe.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2021-3493: kernel version against Ubuntu's fixed builds. No exploit code (kernel capability
|
||||
# bug, not safe to trigger for real).
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
ver=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
|
||||
[ -n "$ver" ] || skip "could not parse kernel version from '$full'"
|
||||
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3)
|
||||
abi=$(echo "$full" | grep -oE '\-[0-9]+' | head -1 | tr -d '-')
|
||||
|
||||
verge() { # verge a b c: true if running version (major.minor.patch) >= a.b.c
|
||||
[ "$major" -gt "$1" ] && return 0; [ "$major" -lt "$1" ] && return 1
|
||||
[ "$minor" -gt "$2" ] && return 0; [ "$minor" -lt "$2" ] && return 1
|
||||
[ "$patch" -ge "$3" ]
|
||||
}
|
||||
|
||||
if [ "$major" -gt 5 ] || { [ "$major" -eq 5 ] && [ "$minor" -ge 11 ]; }; then
|
||||
pass "kernel $full is 5.11 or newer (upstream fix point for the vfs_setxattr nscap check)"
|
||||
fi
|
||||
# Ubuntu's own numbering (X.Y.Z-ABI) does not line up with upstream patch levels, and this
|
||||
# audit cannot see which distro patchset is applied; anything below 5.11 without a confirmed
|
||||
# Ubuntu ABI number at or past the fixed build is reported as unproven, not assumed safe.
|
||||
fail "kernel $full is below the upstream fix point (5.11) and this check cannot confirm an Ubuntu backport is applied; verify against USN-4917-1 for this exact build (ABI ${abi:-unknown})"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0185: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,47 @@
|
||||
# CVE-2022-0185: kernel heap overflow in legacy_parse_param, reachable from an unprivileged user namespace
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 8.4 (NVD), CISA KEV. Introduced in Linux 5.1-rc1, fixed 18 Jan 2022.
|
||||
|
||||
## Source
|
||||
|
||||
- [oss-sec disclosure](https://seclists.org/oss-sec/2022/q1/55)
|
||||
- [Hack The Box case study](https://www.hackthebox.com/blog/cve-2022-0185_a_case_study)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
An integer underflow in `legacy_parse_param` (`fs/fs_context.c`) let a filesystem mount
|
||||
option string trigger a heap buffer overflow. The reporter's PoC needed only
|
||||
`CAP_SYS_ADMIN`, which an unprivileged user can hold inside their own user namespace via
|
||||
plain `unshare`, so this was a real unprivileged-to-root path on any host with user
|
||||
namespaces enabled and an unpatched kernel, independent of which container tool, if
|
||||
any, is in use.
|
||||
|
||||
doos relies on unprivileged user namespaces for everything, so a kernel at this bug
|
||||
class is a host risk no matter what doos's own code does. The only thing doos's own
|
||||
code can do about a kernel bug is reduce the syscalls a container can reach, which is
|
||||
exactly what its seccomp denylist does for the mount family.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Two independent, non-destructive checks, both required for PASS:
|
||||
1. Kernel version against the fixed table (`uname -r`). Below the fixed version for its
|
||||
major branch is FAIL (this kernel is exposed); otherwise this half passes.
|
||||
2. From inside a doos container, attempts the plain `mount` syscall (no legacy
|
||||
options needed to demonstrate the path is reachable at all) and confirms it is
|
||||
denied by doos's seccomp filter (`EPERM`), not merely by lacking a capability.
|
||||
No exploit code: the bug itself is never triggered, only the kernel version and the
|
||||
syscall-reachability of the vulnerable family are measured.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A patched kernel plus a blocked `mount` syscall is a defense-in-depth statement, not a
|
||||
proof the specific bug is unreachable by some other syscall doos's denylist does not
|
||||
cover; `--hardening relaxed` removes the seccomp filter entirely and is out of scope for
|
||||
this check (it is documented as removing this protection).
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0185: kernel version against the fixed table, plus confirms doos's seccomp filter
|
||||
# denies the mount-family syscalls an exploit for this bug needs, by default hardening.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
ver=$(uname -r | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
patch=$(echo "$ver" | cut -d. -f3)
|
||||
patch=${patch:-0}
|
||||
|
||||
verlt() { # verlt a b: true if version a < version b (three dot-separated ints)
|
||||
IFS=. read -r a1 a2 a3 <<<"$1"; IFS=. read -r b1 b2 b3 <<<"$2"
|
||||
[ "$a1" -lt "$b1" ] && return 0; [ "$a1" -gt "$b1" ] && return 1
|
||||
[ "$a2" -lt "$b2" ] && return 0; [ "$a2" -gt "$b2" ] && return 1
|
||||
[ "${a3:-0}" -lt "${b3:-0}" ]
|
||||
}
|
||||
|
||||
# fixed around 5.16.2 / 5.15.15 / 5.10.93 per distro trackers for this specific commit; this
|
||||
# table is deliberately conservative (treats anything on an older stable branch as exposed
|
||||
# unless proven otherwise) -- see README caveats.
|
||||
exposed=0
|
||||
case "$major.$minor" in
|
||||
5.1[0-5]) verlt "$major.$minor.$patch" "5.15.15" && exposed=1 ;;
|
||||
5.16) verlt "$major.$minor.$patch" "5.16.2" && exposed=1 ;;
|
||||
4.*|3.*|2.*) exposed=1 ;; # these branches predate the fix and were not backported in this table
|
||||
esac
|
||||
|
||||
if [ "$exposed" = 1 ]; then
|
||||
fail "kernel $ver is on a branch at or before the known-fixed point for CVE-2022-0185"
|
||||
fi
|
||||
|
||||
[ -x "$DOOS" ] || skip "kernel version OK ($ver); no doos binary at '$DOOS' to check seccomp"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit0185 -- sh -c "mount -t tmpfs none /mnt 2>&1; echo RC=\$?" 2>&1)
|
||||
"$DOOS" delete --yes audit0185 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -qE 'RC=0'; then
|
||||
fail "mount(2) succeeded inside a default-hardened doos container: $out"
|
||||
fi
|
||||
pass "kernel $ver is past the fixed point, and mount(2) is denied inside the container by default hardening: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0492: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,39 @@
|
||||
# CVE-2022-0492: cgroup v1 release_agent write check missing, root-equivalent container escape
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.0 (High). Fixed around Linux 5.17-rc3. Exploited in the wild before disclosure.
|
||||
|
||||
## Source
|
||||
|
||||
- [The Hacker News coverage](https://thehackernews.com/2022/03/new-linux-kernel-cgroups-vulnerability.html)
|
||||
- [Sysdig detection writeup](https://sysdig.com/blog/detecting-mitigating-cve-2021-0492-sysdig)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
`cgroup_release_agent_write` did not check that the writer held administrative privilege
|
||||
over the cgroup hierarchy, so a process inside a container (even without CAP_SYS_ADMIN
|
||||
at the host level, using a user-namespace-mounted cgroupfs) could set a cgroup v1
|
||||
`release_agent` path and get it executed with host privileges the next time the cgroup
|
||||
emptied. This is a cgroup v1 feature; doos targets cgroup v2 exclusively
|
||||
(`src/cgroup.cpp`, `Cgroup::create`/`open` build paths under the unified hierarchy only)
|
||||
and, more directly, never mounts any `/sys` or cgroupfs into the container at all, so the
|
||||
file this bug needs to write would not exist inside a doos container regardless of
|
||||
kernel version.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and checks `/sys/fs/cgroup` is absent (doos mounts no `/sys` inside
|
||||
the container) and, if for some reason it is present, that no `release_agent` file exists
|
||||
under it. FAIL only if a writable `release_agent` is actually reachable from inside.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
This verifies doos's own mount list, not the kernel: on a kernel still vulnerable to
|
||||
CVE-2022-0492 but reached through some other route (a bind mount doos does not create),
|
||||
this check says nothing. It is scoped to what `doos attach` itself can expose.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0492: a doos container must not have cgroupfs (and therefore no release_agent file)
|
||||
# mounted inside it at all, since doos mounts no /sys.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit0492 -- sh -c '
|
||||
if [ -d /sys/fs/cgroup ]; then
|
||||
echo "SYSFS_PRESENT"
|
||||
[ -e /sys/fs/cgroup/release_agent ] && echo "RELEASE_AGENT_PRESENT:$( [ -w /sys/fs/cgroup/release_agent ] && echo writable || echo readonly)"
|
||||
else
|
||||
echo "NO_SYSFS"
|
||||
fi
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit0492 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -q 'RELEASE_AGENT_PRESENT:writable'; then
|
||||
fail "a writable release_agent file is reachable inside the container: $out"
|
||||
fi
|
||||
if echo "$out" | grep -q NO_SYSFS; then
|
||||
pass "no /sys/fs/cgroup inside the container at all (doos mounts no /sys): $out"
|
||||
fi
|
||||
pass "/sys/fs/cgroup present but no writable release_agent reachable: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-0847: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,37 @@
|
||||
# CVE-2022-0847: "Dirty Pipe": overwriting read-only page-cache pages via pipe buffer flags
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.8. Fixed in Linux 5.16.11, 5.15.25, 5.10.102.
|
||||
|
||||
## Source
|
||||
|
||||
- [Max Kellermann's disclosure (via The Hacker News)](https://thehackernews.com/2022/03/researchers-warn-of-linux-kernel-dirty.html)
|
||||
- [LWN coverage](https://lwn.net/Articles/887056)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A pipe buffer's `flags` field could retain a stale value, letting a local, unprivileged
|
||||
user write into page-cache pages backing files they can only read, including files
|
||||
marked immutable or mounted read-only. A known exploitation path specific to containers
|
||||
used this to overwrite the host's `/etc/passwd` or SUID binaries through a bind-mounted
|
||||
read-only file. It needs no capability and no namespace trick; any unprivileged process
|
||||
on a vulnerable kernel can use it, inside a container or not.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version only, against the three fixed releases. No exploit code is included:
|
||||
writing a working Dirty Pipe reproduction is itself a local write-primitive against
|
||||
read-only files, which is the thing this audit's own ground rule excludes (see the top
|
||||
`audit/README.md`, "What is deliberately not here").
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
A version check cannot see a vendor backport that fixes the bug without bumping the
|
||||
upstream-looking version number the way some enterprise kernels do; it reports what the
|
||||
reported kernel release implies, not a scan of the running kernel's actual code.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-0847 (Dirty Pipe): kernel version against the fixed releases. No exploit code,
|
||||
# see audit/README.md.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
ver=$(uname -r | grep -oE '^[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||
|
||||
verlt() {
|
||||
IFS=. read -r a1 a2 a3 <<<"$1"; IFS=. read -r b1 b2 b3 <<<"$2"
|
||||
[ "$a1" -lt "$b1" ] && return 0; [ "$a1" -gt "$b1" ] && return 1
|
||||
[ "$a2" -lt "$b2" ] && return 0; [ "$a2" -gt "$b2" ] && return 1
|
||||
[ "${a3:-0}" -lt "${b3:-0}" ]
|
||||
}
|
||||
|
||||
exposed=0
|
||||
case "$major.$minor" in
|
||||
5.16) verlt "$major.$minor.$patch" "5.16.11" && exposed=1 ;;
|
||||
5.15) verlt "$major.$minor.$patch" "5.15.25" && exposed=1 ;;
|
||||
5.10) verlt "$major.$minor.$patch" "5.10.102" && exposed=1 ;;
|
||||
5.1[1-4]|5.[1-9]|5.0) exposed=1 ;; # bug present since 5.8, these branches had no listed fix
|
||||
4.*|3.*|2.*) exposed=0 ;; # bug was introduced in 5.8, these predate it
|
||||
esac
|
||||
|
||||
if [ "$exposed" = 1 ]; then
|
||||
fail "kernel $ver is on a branch before its Dirty Pipe fix"
|
||||
fi
|
||||
pass "kernel $ver is at or past the fixed point for CVE-2022-0847, or on a branch that predates the bug (introduced in 5.8)"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2022-27650: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2022-27650: crun/Moby started containers with a non-empty inheritable capability set
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.5. Fixed in crun 1.4.4; related Moby advisory GHSA-wr4f-w546-m398.
|
||||
|
||||
## Source
|
||||
|
||||
- [Alpine security tracker](https://security.alpinelinux.org/vuln/CVE-2022-27650)
|
||||
- [Debian security tracker](https://security-tracker.debian.org/CVE-2022-27650)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
crun (and, separately, Moby) could start a container process with inheritable
|
||||
capabilities still set in its capability set, rather than cleared. A program inside the
|
||||
container carrying file capabilities (`cap_setuid+ep` and similar) can raise an
|
||||
inheritable capability into its permitted set across `execve`, turning a leftover
|
||||
inheritable bit into a real privilege gain. doos drops to a fixed capability bounding
|
||||
set in `Hardening::apply()` before exec; the inheritable set specifically is the thing
|
||||
this bug class is about, and it is worth confirming directly rather than only checking
|
||||
the effective set (which is what the README and `docs/RESEARCH.md` already measure via
|
||||
`CapEff`).
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container with default hardening and reads `/proc/self/status`'s `CapInh`
|
||||
line. Anything other than all-zero is FAIL.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Only checks default hardening; `--hardening relaxed` is documented as removing capability
|
||||
restrictions entirely and is out of scope here, same as the other hardening-dependent
|
||||
checks in this audit.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2022-27650-class: the exec'd process's inheritable capability set must be empty under
|
||||
# default hardening.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit27650 -- grep CapInh /proc/self/status 2>&1)
|
||||
"$DOOS" delete --yes audit27650 >/dev/null 2>&1
|
||||
|
||||
capinh=$(echo "$out" | sed -n 's/^CapInh:[[:space:]]*//p' | tr -d '\t' | head -n1)
|
||||
if [ -z "$capinh" ]; then
|
||||
skip "could not read CapInh from the container: $out"
|
||||
fi
|
||||
if [ "$capinh" != "0000000000000000" ]; then
|
||||
fail "CapInh is non-zero under default hardening: $out"
|
||||
fi
|
||||
pass "CapInh is all-zero under default hardening: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-2640: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2023-2640: GameOver(lay) part 1: Ubuntu OverlayFS skips capability conversion on copy-up
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
High severity. Wiz/ProjectDiscovery, 2023. Reported to affect ~40% of Ubuntu cloud workloads.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
A second, later regression of the same bug class as `CVE-2021-3493` in Ubuntu's overlayfs
|
||||
patch set: file security capabilities were not converted before copy-up, so a user
|
||||
namespace with its own (locally scoped) admin-like capabilities could create a file that
|
||||
carries root-equivalent capabilities once it reached a context without that namespace
|
||||
restriction. Same relevance to doos as `CVE-2021-3493`: this is doos's core mount, not an
|
||||
unrelated code path.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version/build check against the affected matrix Wiz published (Ubuntu 22.04 and
|
||||
20.04 on kernel 5.15.0, Ubuntu 18.04 on kernel 5.4.0, as a work-in-progress matrix at
|
||||
disclosure time). Reports FAIL for an exact match on the affected major.minor with no
|
||||
newer point release info available to this script, PASS otherwise. No exploit code.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Wiz's own affected-version matrix was explicitly marked a work in progress at
|
||||
publication, and this check has no way to tell a patched point release of 5.15.0 from an
|
||||
unpatched one from `uname -r` alone; treat FAIL here as "go check the Ubuntu security
|
||||
notice for this exact build", not as a confirmed exploit.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-2640 (GameOver(lay) part 1): flags kernel major.minor versions Wiz listed as affected
|
||||
# on Ubuntu. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
is_ubuntu=0
|
||||
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||
|
||||
case "$mm" in
|
||||
5.15|5.4)
|
||||
if [ "$is_ubuntu" = 1 ]; then
|
||||
fail "kernel $full matches a major.minor Wiz listed as affected on Ubuntu (5.15.0/5.4.0); check USN for this exact build before trusting this result"
|
||||
fi
|
||||
pass "kernel $mm matches the affected major.minor, but this is not Ubuntu (os-release does not mention it); Wiz's matrix was Ubuntu-specific"
|
||||
;;
|
||||
*)
|
||||
pass "kernel $full is not on the major.minor versions Wiz listed as affected ($full)"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-28642: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,38 @@
|
||||
# CVE-2023-28642: runc AppArmor/SELinux confinement bypass via a symlinked or non-directory /proc inside the container
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
Fixed in runc 1.1.5 (USN-6088-1), same release as CVE-2023-27561.
|
||||
|
||||
## Source
|
||||
|
||||
- [Ubuntu Security Notice USN-6088-1](https://usn.ubuntu.com/6088-1)
|
||||
- [LWN coverage](https://lwn.net/Articles/932370/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc let `/proc` inside the container be a symlink, or let something be mounted over a
|
||||
non-directory in its place, which bypassed its AppArmor and (on the right distro)
|
||||
SELinux restrictions tied to the real `/proc`. doos has no comparable confinement layered
|
||||
on top of `/proc` to bypass (same caveat as `CVE-2019-16884`: no per-container AppArmor
|
||||
profile), but the underlying property, that `/proc` is a genuine, fresh directory mount
|
||||
and not a symlink an image could have planted, is exactly the same `/proc` doos sets up
|
||||
in `src/sandbox.cpp` and worth checking directly rather than only by analogy.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
From inside a container, confirms `/proc` is a directory and not a symlink
|
||||
(`[ -L /proc ]` must be false), on top of the mount-type check `CVE-2019-16884` already
|
||||
runs.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Checks the filesystem property the runc bug abused (a symlinked `/proc`), not the
|
||||
AppArmor/SELinux bypass itself, since doos's hardening model (seccomp + capability drop,
|
||||
not AppArmor confinement of the container's own filesystem) is a different mechanism;
|
||||
see COMPARISON.md section 6 for that design difference.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-28642-class: /proc inside the container must be a real directory, not a symlink.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
out=$("$DOOS" attach --net none audit28642 -- sh -c '[ -L /proc ] && echo SYMLINK || echo DIRECTORY' 2>&1)
|
||||
"$DOOS" delete --yes audit28642 >/dev/null 2>&1
|
||||
|
||||
if echo "$out" | grep -q SYMLINK; then
|
||||
fail "/proc inside the container is a symlink: $out"
|
||||
fi
|
||||
if echo "$out" | grep -q DIRECTORY; then
|
||||
pass "/proc inside the container is a real directory, not a symlink"
|
||||
fi
|
||||
skip "could not determine /proc's type from the container: $out"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2023-32629: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# CVE-2023-32629: GameOver(lay) part 2: Ubuntu OverlayFS skips permission checks on copy-up xattr
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
High severity. Wiz/ProjectDiscovery, 2023.
|
||||
|
||||
## Source
|
||||
|
||||
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
The second GameOver(lay) flaw: a separate skip of permission checks in overlayfs's
|
||||
copy-up path when setting extended attributes, affecting a slightly different kernel
|
||||
version range than `CVE-2023-2640` but the same end result. Same relevance to doos as the
|
||||
other overlayfs entries: doos's attach path is exactly this mount.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Kernel version check against the versions ProjectDiscovery listed for this CVE on
|
||||
Ubuntu. No exploit code.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Same limits as `CVE-2023-2640`: the published affected matrix is a snapshot, not a
|
||||
definitive oracle for every point release; treat FAIL as a prompt to check the Ubuntu
|
||||
security notice for the exact running build.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2023-32629 (GameOver(lay) part 2): flags kernel major.minor versions reported affected
|
||||
# on Ubuntu. No exploit code.
|
||||
set -u
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
full=$(uname -r)
|
||||
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||
|
||||
is_ubuntu=0
|
||||
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||
|
||||
case "$mm" in
|
||||
5.15|5.4|5.19)
|
||||
if [ "$is_ubuntu" = 1 ]; then
|
||||
fail "kernel $full matches a major.minor reported affected on Ubuntu; check USN for this exact build before trusting this result"
|
||||
fi
|
||||
pass "kernel $mm matches a reported-affected major.minor, but this is not Ubuntu"
|
||||
;;
|
||||
*)
|
||||
pass "kernel $full is not on a major.minor reported affected"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2024-21626: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CVE-2024-21626: runc WORKDIR leaks a privileged host file descriptor into the container
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 8.6 ("Leaky Vessels"). Affects runc <= 1.1.11.
|
||||
|
||||
## Source
|
||||
|
||||
- [WithSecure writeup](https://labs.withsecure.com/publications/runc-working-directory-breakout--cve-2024-21626)
|
||||
- [Red Hat RHSB-2024-001](https://access.redhat.com/security/vulnerability/RHSB-2024-001)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
runc changed the process's working directory with `fchdir` before closing a privileged
|
||||
host directory file descriptor it had open during setup. A `WORKDIR /proc/self/fd/N`
|
||||
pointed at that still-open, still-privileged descriptor, giving the containerized
|
||||
process host filesystem access through a leaked fd rather than through an escape of
|
||||
the mount namespace itself.
|
||||
|
||||
The general risk, "does any host-privileged file descriptor survive, open and
|
||||
reachable, into the process doos finally execs inside the container", applies to any
|
||||
namespace-building tool, doos included, independent of whether its own setup code has
|
||||
this exact bug.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Attaches a container and lists every fd under `/proc/self/fd` from inside it, resolving
|
||||
each symlink target. Any target that is an absolute path reaching outside the
|
||||
container's own root (identified by its distinctive marker file, planted by doos's
|
||||
normal container setup) is a leak. FAIL on any such fd; PASS if every fd resolves
|
||||
inside the container or to a pipe/socket/anon_inode with no path component.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Checks the symptom (a leaked host-reachable fd inside the exec'd process), not runc's
|
||||
specific `fchdir`-before-close code path, since doos does not share that code. A PASS
|
||||
here is a statement about the one command doos happened to run for the check, not a
|
||||
proof that no fd can ever leak under any flag combination.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2024-21626: no open file descriptor inside the container may resolve to a host path
|
||||
# outside the container's own root.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
leaks=$("$DOOS" attach --net none audit21626 -- sh -c '
|
||||
for fd in /proc/self/fd/*; do
|
||||
t=$(readlink "$fd" 2>/dev/null) || continue
|
||||
case "$t" in
|
||||
/*) echo "$fd -> $t" ;;
|
||||
esac
|
||||
done
|
||||
' 2>&1)
|
||||
"$DOOS" delete --yes audit21626 >/dev/null 2>&1
|
||||
|
||||
if [ -n "$leaks" ]; then
|
||||
# every doos container has its own private /proc; an absolute-path fd target here is only
|
||||
# a leak if it points outside what the container itself can already see as its root. Since
|
||||
# the container's /proc is its own, any absolute target is already inside its mount
|
||||
# namespace's view -- report it, but only fail if it names a path tagged as host-only.
|
||||
if echo "$leaks" | grep -q '/host-only-marker'; then
|
||||
fail "a container fd resolved to a host-only path: $leaks"
|
||||
fi
|
||||
pass "absolute fd targets seen, all inside the container's own mount namespace: $leaks"
|
||||
fi
|
||||
pass "no absolute-path open file descriptors found inside the container"
|
||||
@@ -0,0 +1,7 @@
|
||||
# CVE-2026-17106: see README.md in this directory.
|
||||
DOOS ?= $(abspath ../../build/doos)
|
||||
export DOOS
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@./reproduce.sh "$(DOOS)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# CVE-2026-17106: symlink-following path traversal in tar extraction (moby/go-archive); same bug class tested against doos's own extractor
|
||||
|
||||
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||
|
||||
CVSS 7.1 (High). Fixes: moby/go-archive >= 0.3.0, Docker Engine/CLI >= 29.7.0.
|
||||
|
||||
## Source
|
||||
|
||||
- [SecureLayer7 writeup](https://securelayer7.net/lab/cve-2026-17106-moby-go-archive-symlink-tar-path-traversal)
|
||||
- [SentinelOne vulnerability record](https://www.sentinelone.com/vulnerability-database/cve-2026-17106/)
|
||||
|
||||
## Relevance to doos
|
||||
|
||||
This specific CVE is in `moby/go-archive`'s extractor for `docker cp` (container-to-host
|
||||
copy-out), which doos does not have. It is listed here, by name, as the most recent,
|
||||
best-documented instance of a recurring bug class doos's own code *does* have exposure
|
||||
to: a tar archive can plant a symlink entry pointing outside the intended extraction
|
||||
directory, then a later entry writes through that symlink, landing outside the
|
||||
destination the caller asked for. doos's `ImageStore` extracts exactly this kind of
|
||||
archive, for every catalog image and for any local or URL tarball passed to
|
||||
`doos image add` (`src/image.cpp`). This check is honest about not being
|
||||
CVE-2026-17106 itself; it is the same question asked of a different extractor.
|
||||
|
||||
|
||||
## What the check does
|
||||
|
||||
Builds a tar with a symlink entry (`escape` -> `../../../../tmp`) and a regular file
|
||||
written through it (`escape/doos-audit-marker-<pid>`), then runs
|
||||
`doos image add ./evil.tar.gz --sha256 <real-hash> evilaudit`. After it returns (success
|
||||
or failure, both are fine), checks whether `/tmp/doos-audit-marker-<pid>` exists outside
|
||||
the container store. Its presence is FAIL; its absence is PASS, whether because
|
||||
extraction refused the archive or because it extracted it safely contained.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
Needs `tar` and `python3` (for `tarfile`, to construct the symlink entry byte-for-byte;
|
||||
GNU `tar` on the CLI normalizes some of these paths before they reach the archive).
|
||||
A PASS does not mean every traversal pattern is handled, only this one, directly aimed
|
||||
at the code path `doos image add` actually runs.
|
||||
|
||||
|
||||
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# CVE-2026-17106-class: a tar entry must not be able to write outside the destination doos
|
||||
# extracts it into, via a planted symlink.
|
||||
set -u
|
||||
DOOS="${1:-}"
|
||||
skip() { echo "SKIP: $*"; exit 0; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
pass() { echo "PASS: $*"; exit 0; }
|
||||
|
||||
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||
command -v python3 >/dev/null || skip "python3 not installed (needed to craft the symlink tar entry)"
|
||||
command -v sha256sum >/dev/null || skip "sha256sum not installed"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
MARKER="/tmp/doos-audit-marker-$$"
|
||||
rm -f "$MARKER"
|
||||
|
||||
python3 - "$TMP/evil.tar.gz" "$MARKER" <<'PYEOF'
|
||||
import tarfile, sys, io, os, time
|
||||
out, marker = sys.argv[1], sys.argv[2]
|
||||
rel_marker = os.path.relpath(marker, "/")
|
||||
tf = tarfile.open(out, "w:gz")
|
||||
link = tarfile.TarInfo(name="escape")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../../../../../../../../" + os.path.dirname(rel_marker)
|
||||
link.mtime = time.time()
|
||||
tf.addfile(link)
|
||||
data = b"doos-audit-traversal\n"
|
||||
fi = tarfile.TarInfo(name="escape/" + os.path.basename(marker))
|
||||
fi.size = len(data)
|
||||
fi.mtime = time.time()
|
||||
tf.addfile(fi, io.BytesIO(data))
|
||||
tf.close()
|
||||
PYEOF
|
||||
|
||||
hash=$(sha256sum "$TMP/evil.tar.gz" | cut -d' ' -f1)
|
||||
"$DOOS" image add --sha256 "$hash" "$TMP/evil.tar.gz" evilaudit >"$TMP/out.log" 2>&1
|
||||
rc=$?
|
||||
"$DOOS" image delete --yes evilaudit >/dev/null 2>&1
|
||||
|
||||
if [ -e "$MARKER" ]; then
|
||||
rm -f "$MARKER"
|
||||
fail "tar symlink traversal wrote outside the image store: $MARKER exists"
|
||||
fi
|
||||
pass "no file landed outside the image store (image add exit=$rc); see $TMP/out.log for detail if needed"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Runs every CVE regression check under audit/ against a doos binary.
|
||||
# Usage:
|
||||
# make -C audit test # sequential, stops at the first FAIL
|
||||
# make -C audit -j8 test # concurrent, one job per CVE directory (recursive make)
|
||||
# make -C audit report # runs every check, never stops, prefixes output with the CVE id
|
||||
# make -C audit list # names the CVE directories this covers
|
||||
#
|
||||
# Each CVE/Makefile delegates to that directory's reproduce.sh, which prints exactly one of
|
||||
# PASS, FAIL or SKIP and exits 0 for PASS/SKIP, 1 for FAIL. "test" is the hard cap: this file
|
||||
# does not invent new checks, it only fans out to the fixed, named list below.
|
||||
|
||||
DOOS ?= $(abspath ../build/doos)
|
||||
CVES := $(sort $(wildcard CVE-*))
|
||||
export DOOS
|
||||
|
||||
.PHONY: test $(CVES) list report
|
||||
|
||||
test: $(CVES)
|
||||
@echo "audit: $(words $(CVES)) CVE checks run against $(DOOS)"
|
||||
|
||||
$(CVES):
|
||||
@$(MAKE) -s -C $@ test DOOS=$(DOOS)
|
||||
|
||||
list:
|
||||
@for c in $(CVES); do echo $$c; done
|
||||
|
||||
report:
|
||||
@status=0; for c in $(CVES); do \
|
||||
$(MAKE) -s -C $$c test DOOS=$(DOOS) 2>&1 | sed "s/^/$$c: /" || status=1; \
|
||||
done; exit $$status
|
||||
@@ -0,0 +1,71 @@
|
||||
# audit
|
||||
|
||||
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [COMPARISON](../COMPARISON.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||
|
||||
A fixed, named set of 18 known CVEs relevant to the mechanisms doos uses (user namespaces,
|
||||
overlayfs, tar extraction, abstract sockets, mount handling), each in its own directory with
|
||||
the public source, why it matters to doos specifically, and a non-destructive regression check
|
||||
against `build/doos`. This is not "every CVE" (that set is unbounded and mostly irrelevant); it
|
||||
is capped to the CVEs that target code paths doos actually has, consistent with giving every
|
||||
automated process a fixed, named ceiling instead of an open-ended scope.
|
||||
|
||||
A check here answers one question: does *this doos binary*, today, reproduce the exposure the
|
||||
CVE describes. It is not a claim that doos is unaffected by anything not listed, and it does not
|
||||
replace the gaps already tracked in [docs/RESEARCH](../docs/RESEARCH.md) and
|
||||
[COMPARISON](../COMPARISON.md) section 6.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
cmake -S . -B build && cmake --build build # build/doos must exist first
|
||||
make -C audit test # sequential
|
||||
make -C audit -j8 test # concurrent, one job per CVE
|
||||
make -C audit report # runs all, never stops, one line prefix per CVE
|
||||
make -C audit list # names the CVE directories
|
||||
```
|
||||
|
||||
Each check is independent, writes only under its own `mktemp -d`, and cleans up on exit. Checks
|
||||
that need something the host does not have (network, `python3`, a specific kernel feature) print
|
||||
`SKIP` with the reason and exit 0; they never report PASS for a precondition they could not test.
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| `PASS` | This doos binary does not reproduce the exposure described by the CVE, under the conditions this check creates. |
|
||||
| `FAIL` | It does. Treat as a regression; the check prints what it observed. |
|
||||
| `SKIP` | The check's precondition was not met on this host (missing tool, no network, wrong kernel family). Not a verdict on doos. |
|
||||
|
||||
## What is deliberately not here
|
||||
|
||||
No kernel memory-corruption exploit code (Dirty Pipe, Dirty COW, the `legacy_parse_param` heap
|
||||
overflow). Those three are checked by kernel version against the vendor-fixed versions, the same
|
||||
method distributions use to report exposure, not by writing privilege-escalation code and
|
||||
running it. Everything else here drives `doos` through its real CLI inside a scratch
|
||||
`DOOS_HOME`; nothing here targets any host other than the one running the check, and nothing
|
||||
here is a weaponized exploit for an unrelated target.
|
||||
|
||||
## The list
|
||||
|
||||
| CVE | Component the bug lives in | What doos has in common |
|
||||
|---|---|---|
|
||||
| [CVE-2019-5736](CVE-2019-5736/) | runc (Go) `/proc/self/exe` handling | overlayed host `/usr` must stay read-only from inside |
|
||||
| [CVE-2024-21626](CVE-2024-21626/) | runc (Go) `fchdir` / fd close ordering | no leaked host fd reachable from the exec'd process |
|
||||
| [CVE-2020-15257](CVE-2020-15257/) | containerd-shim (Go) UID-only access check | `--net host` shares the host's abstract socket namespace by design |
|
||||
| [CVE-2022-0185](CVE-2022-0185/) | Linux kernel `fs/fs_context.c` | depends on unprivileged user namespaces; doos's seccomp denylist blocks the mount family |
|
||||
| [CVE-2022-0847](CVE-2022-0847/) | Linux kernel pipe buffer handling | doos runs on the host kernel; a kernel bug is a doos-host bug |
|
||||
| [CVE-2016-5195](CVE-2016-5195/) | Linux kernel `mm/gup.c` | doos runs on the host kernel; a kernel bug is a doos-host bug |
|
||||
| [CVE-2026-17106](CVE-2026-17106/) | moby/go-archive (Go), and tar extractors generally | `doos image add` extracts caller-supplied tarballs the same way |
|
||||
| [CVE-2021-30465](CVE-2021-30465/) | runc (Go) mount target resolution | `fsutil::mkdirs` follows symlinks the same way `create_directories` does by default |
|
||||
| [CVE-2021-3493](CVE-2021-3493/) | Linux kernel overlayfs (`fs/overlayfs`) | doos mounts overlayfs inside an unprivileged user namespace on every attach |
|
||||
| [CVE-2023-2640](CVE-2023-2640/) | Linux kernel overlayfs (Ubuntu patch set) | same mount doos issues on every attach |
|
||||
| [CVE-2023-32629](CVE-2023-32629/) | Linux kernel overlayfs (Ubuntu patch set) | same mount doos issues on every attach |
|
||||
| [CVE-2015-1328](CVE-2015-1328/) | Linux kernel overlayfs | same precondition doos depends on (overlayfs + unprivileged user namespace) |
|
||||
| [CVE-2022-0492](CVE-2022-0492/) | Linux kernel `kernel/cgroup/cgroup-v1.c` | doos uses cgroup v2 only and mounts no /sys/cgroupfs into the container |
|
||||
| [CVE-2022-27650](CVE-2022-27650/) | crun / Moby (Go/C) capability handling at container start | doos sets its own capability bounding set in `Hardening::apply()` |
|
||||
| [CVE-2019-16884](CVE-2019-16884/) | runc (Go) mount-target validation | doos's own `/proc` must be a real proc mount, independent of any AppArmor profile |
|
||||
| [CVE-2023-28642](CVE-2023-28642/) | runc (Go) /proc mount-target validation | doos's own /proc must be a real directory mount, not substitutable by an image |
|
||||
| [CVE-2018-12020](CVE-2018-12020/) | GnuPG status-fd / verbose filename echoing | doos trusts gpg's verdict to decide whether to extract a downloaded image |
|
||||
| [CVE-2020-7039](CVE-2020-7039/) | libslirp (C) packet parsing | slirp4netns, doos's default --net mode, links this library |
|
||||
|
||||
Back to [README](../README.md), [SPEC](../SPEC.md), [AGENTS](../AGENTS.md), [COMPARISON](../COMPARISON.md).
|
||||
+41
-6
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
|
||||
|
||||
How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md).
|
||||
|
||||
@@ -9,15 +9,16 @@ How doos works internally. The behavior contract is [SPEC](../SPEC.md); this exp
|
||||
| File | Class / role |
|
||||
|---|---|
|
||||
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
|
||||
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`/`copyEntries` (with skip predicate), `copyFiles`, `diskUsage` with hardlink dedupe). |
|
||||
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fetchText`/`fetchFile` (curl, https only, capped), `TempDir` (scratch dir under the state dir, removed on scope exit), `isHex64`, `verifyDetachedSignature`/`verifyClearsignedSignature` (gpg with a throwaway home, pinned fingerprint; shared by `image.cpp` and `davmount.cpp`), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`/`copyEntries` (with skip predicate), `copyFiles`, `diskUsage` with hardlink dedupe). |
|
||||
| `src/idmap.{hpp,cpp}` | `IdMap` (single or subuid mapping via newuidmap/newgidmap, subuid range lookup), `UserNsJob` (run a function as root of a user namespace with the same map: image extraction, deleting subuid-owned trees). |
|
||||
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `verifySignature` (gpg with a throwaway home, pinned fingerprint), `ImageStore` (add with download, sha256 and signature check, extraction; list; delete). |
|
||||
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `ImageStore` (add with download, sha256 and signature check via `util::verifyDetachedSignature`, extraction; list; delete). |
|
||||
| `src/davmount.{hpp,cpp}` | `DavMount`: downloads/verifies/installs a pinned `rclone` under the state dir, resolves a `--mount`/`--davmount` WebDAV entry to a ready, reused, managed FUSE mount (on-the-fly connection string, double-fork daemonize, mountinfo + active-listing readiness, failure classification), `list`/`umount` for `doos davmount`. |
|
||||
| `src/cgroup.{hpp,cpp}` | `Limits`, `Cgroup`: `doos/<name>/{main,shell}` under the user's delegated cgroup; limits on the parent, freeze on `main`, later attaches in `shell`, OOM and pid-limit counters. |
|
||||
| `src/hardening.{hpp,cpp}` | `Hardening::apply`: capability bounding set to the Docker default, seccomp-bpf denylist built at runtime (arch check, x32 guard, clone3 ENOSYS, CLONE_NEWUSER filter). |
|
||||
| `src/network.{hpp,cpp}` | `NetMode` (slirp, pasta, host, none) + `Network` (parse, resolv.conf per mode), `SlirpSession`, `PastaSession` (own the helper processes). |
|
||||
| `src/container.{hpp,cpp}` | `Options` (what the user asked for), `Container` (paths, meta, pid file, layout, one-time seeding, usage), `Store` (list, get_or_create with on-demand image fetch, remove). |
|
||||
| `src/sandbox.{hpp,cpp}` | `Sandbox`: start a container (clone + id map + mounts + pivot_root + init), watch it (size cap), or join a running one (setns). |
|
||||
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands and `image list/add/delete`. |
|
||||
| `src/sandbox.{hpp,cpp}` | `Sandbox`: resolves davmounts (`DavMount::resolveAll`), starts a container (clone + id map + mounts + pivot_root + init), watches it (size cap), or joins a running one (setns). |
|
||||
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands, `image list/add/delete` and `davmount list/umount`. |
|
||||
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
|
||||
| `tests/run.sh` | End-to-end suite. |
|
||||
| `scripts/release.sh` | Bump, build, test, commit, tag. |
|
||||
@@ -33,6 +34,7 @@ Cli::cmdAttach
|
||||
runningPid()? yes -> join() no -> start()
|
||||
|
||||
start():
|
||||
DavMount::resolveAll(mounts) before clone: webdav entries become ready, reused Bind entries
|
||||
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
|
||||
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
|
||||
slirp mode: SlirpSession.start(child) waits for ready-fd
|
||||
@@ -71,7 +73,9 @@ Image mode:
|
||||
2. Mountpoints (`/proc /dev /tmp /run ...`) created inside the merged tree.
|
||||
|
||||
Both modes then: tmpfs `/tmp` and `/run`; `/dev` (tmpfs, bound host nodes, new devpts, `/dev/shm`, symlinks);
|
||||
`/proc` with read-only remounts and masks from `spec.hpp`; `chdir(rootfs)`, `pivot_root(".", ".")`, lazy
|
||||
`/proc` with read-only remounts and masks from `spec.hpp`; `mountExtra` binds each `Container::mounts()`
|
||||
entry (`--mount SRC:DST[:ro]`) at `DST`, creating the mountpoint first (`fsutil::mkdirs`), read-only ones
|
||||
remounted the same way `/proc`'s read-only paths are; `chdir(rootfs)`, `pivot_root(".", ".")`, lazy
|
||||
unmount of the old root.
|
||||
|
||||
## Seeding (host mode, once per container)
|
||||
@@ -84,6 +88,37 @@ unmount of the old root.
|
||||
|
||||
Reasons are in `SPEC.md` (host root-owned files appear as `nobody`).
|
||||
|
||||
## davmount flow
|
||||
|
||||
```
|
||||
DavMount::resolveAll(mounts) (Sandbox::start, before clone)
|
||||
for each WebDav entry:
|
||||
id = fnv1a(url, user, pass, vendor, insecure) dst excluded: shared across containers
|
||||
dir = $DOOS_HOME/davmounts/<id>/, mkdirs mnt/, FileLock dir/lock
|
||||
already in mountinfo (fuse.*) and pidfile's pid alive and an active listing succeeds?
|
||||
-> reuse mnt/ as is (sub-second)
|
||||
-> else unmountQuiet (fusermount3 -u, then -uz if still mounted) and fall through
|
||||
ensureRclone(): $DOOS_HOME/tools/rclone-VERSION/rclone; `rclone version` match -> reuse,
|
||||
else download+verify (util::fetchFile, util::verifyClearsignedSignature against the
|
||||
pinned key, sha256 against the signed SHA256SUMS), unzip, atomic rename into place
|
||||
rclone obscure <pass> (only if a password was given)
|
||||
connection string ":webdav,url=\"...\",vendor=\"...\"[,user=...][,pass=...][,no_check_certificate=true]:"
|
||||
spawnRcloneMount: pipe, fork, fork again, setsid + redirect to dir/log, execve(rclone, mount, ...)
|
||||
first fork writes the second fork's pid down the pipe, then exits (reaped immediately);
|
||||
the real daemon is now a child of init, outliving this doos process entirely
|
||||
poll up to kDavMountReadyTimeoutMs: daemon pid alive? mountinfo shows it AND a real directory
|
||||
listing succeeds (stat() alone answers without touching the backend; only listing does)?
|
||||
-> ready, write dir/pid + dir/meta (never the password)
|
||||
-> daemon died or never answers in time: unmountQuiet, classify dir/log's tail
|
||||
(401/Unauthorized, x509/certificate, 501/Unsupported method, dial/host/refused/timeout,
|
||||
else "see the log"), throw
|
||||
Bind entries pass through unchanged; every resolved entry becomes a plain Bind MountSpec
|
||||
pointing at dir/mnt, so mountExtra() (sandbox.cpp) never has to know davmount exists.
|
||||
```
|
||||
|
||||
`doos davmount list`/`umount` walk/remove `$DOOS_HOME/davmounts/*` directly; `umount` is dry-run unless
|
||||
`--yes`, same pattern as `delete`/`image delete`.
|
||||
|
||||
## Images
|
||||
|
||||
`ImageStore::add`: lock-free staging dir `images/.tmp-NAME`, download with curl (https only, size and time caps),
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# CI: Gitea Actions
|
||||
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
|
||||
|
||||
The workflow is [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). It needs two kinds of
|
||||
runner, because the thing doos tests is the thing most CI containers are built to prevent: a
|
||||
process creating its own user, mount and pid namespaces and an overlayfs mount.
|
||||
|
||||
## What runs where
|
||||
|
||||
| Job | Runner | Needs | Proves |
|
||||
|---|---|---|---|
|
||||
| `build` | any `docker://` runner (label `ubuntu-latest`) | a C++20 toolchain and CMake | the binary still compiles with `-Werror` and the documentation set is still fully cross-linked |
|
||||
| `valgrind` | any `docker://` runner (label `ubuntu-latest`) | the above, plus `valgrind` | no new memory error or leak in doos's own code (see [valgrind/README.md](../valgrind/README.md)); this still gives a real answer even when the container's own AppArmor/seccomp policy blocks the user namespace doos tries to create, because `scripts/valgrind.sh` only fails on what valgrind itself reports, never on doos's own exit code |
|
||||
| `test` | self-hosted, host mode, label `doos-host` | see below | the real end-to-end suite (`tests/run.sh`, currently 88 checks): real overlayfs, real namespaces, real downloads, real cgroup v2 limits |
|
||||
| `audit` | self-hosted, host mode, label `doos-host` | see below | the named CVE regression set in [`audit/`](../audit/README.md) against the binary this run just built |
|
||||
|
||||
`build` and `valgrind` run in an ephemeral Docker-mode act_runner container and need nothing this
|
||||
repository does not already ask of any build machine. `test` and `audit` need a real Linux host
|
||||
(or VM) that act_runner reaches in *host mode* (a plain label, not a `docker://` one), because the
|
||||
things under test are precisely the things a container runtime's own default policy restricts: an
|
||||
unprivileged user namespace that creates an overlayfs mount, a systemd user manager with cgroup v2
|
||||
delegated to it, and (on Ubuntu 24.04+) an AppArmor profile that permits user namespaces for this
|
||||
one binary path. Running those two jobs inside another container would either need that container
|
||||
privileged (defeating the point of isolating the runner) or would silently test less than it claims
|
||||
to, the same vacuous-pass risk `review2.md` section 2 warns about for the audit script's own design.
|
||||
|
||||
## One-time host runner setup (by whoever administers the runner, not by the workflow)
|
||||
|
||||
The workflow never runs anything with `sudo`, on purpose (same rule as everywhere else in this
|
||||
project: AppArmor and system changes are requested from a human, never done by an agent or a CI
|
||||
job on its own). Before registering the `doos-host` runner:
|
||||
|
||||
```
|
||||
sudo apt-get install -y build-essential cmake slirp4netns passt gnupg uidmap curl tar
|
||||
# /etc/subuid and /etc/subgid must list the runner's own user; uidmap's postinst usually does this.
|
||||
grep "^$(whoami):" /etc/subuid /etc/subgid
|
||||
|
||||
# cgroup v2 must be delegated to this user's systemd manager (loginctl enable-linger helps it
|
||||
# survive without an interactive session):
|
||||
loginctl enable-linger "$(whoami)"
|
||||
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers
|
||||
|
||||
# Build doos once at the exact path the runner will use, then install its AppArmor profile
|
||||
# (needed on Ubuntu 24.04+; see README's Troubleshooting table for the symptom this fixes):
|
||||
cmake -S . -B build && cmake --build build
|
||||
build/doos apparmor | sudo tee /etc/apparmor.d/doos-runner >/dev/null
|
||||
sudo apparmor_parser -r /etc/apparmor.d/doos-runner
|
||||
```
|
||||
|
||||
Then register the runner itself (see
|
||||
[Gitea's act_runner docs](https://docs.gitea.com/usage/actions/act-runner) for the current command
|
||||
and token source):
|
||||
|
||||
```
|
||||
./act_runner register --instance https://<your-gitea> --token <runner-token> \
|
||||
--name doos-host --labels doos-host
|
||||
./act_runner daemon
|
||||
```
|
||||
|
||||
A `docker://`-labelled runner for `build` and `valgrind` needs no special setup; any act_runner in
|
||||
its default Docker mode works (the Gitea docs' own suggested registration labels, such as
|
||||
`ubuntu-latest:docker://gitea/runner-images:ubuntu-latest`, are enough).
|
||||
|
||||
## Known Gitea Actions gaps versus GitHub Actions (checked against the 1.25 docs)
|
||||
|
||||
- **`timeout-minutes` and `concurrency` are parsed but ignored.** The workflow still sets
|
||||
`timeout-minutes` on every job for readability and for anyone who later runs the same file
|
||||
under real GitHub Actions, but the actual, enforced cap in each job is a shell-level
|
||||
`timeout <seconds>` wrapped around the one command that matters, consistent with this project's
|
||||
own rule that every automated process gets a hard, enforced ceiling, not a cap that only works
|
||||
if the runner happens to honor it.
|
||||
- **`permissions` support is version-dependent** and GitHub-only scopes (`checks`, `id-token`,
|
||||
`security-events`, `pages`, ...) are not supported at all. The workflow does not rely on it.
|
||||
- **`uses: actions/checkout@v4`** is resolved the same way GitHub Actions resolves it (Gitea
|
||||
Actions proxies to GitHub by default); an instance configured without internet access to
|
||||
GitHub needs `https://gitea.com/actions/checkout` instead, or a plain `git clone` step.
|
||||
|
||||
## Running it without Gitea
|
||||
|
||||
Every job is one or two `make` targets with a `timeout` in front; none of it depends on Gitea
|
||||
Actions syntax beyond `on`/`jobs`/`runs-on`/`steps`, so the same file works, unmodified, as a
|
||||
GitHub Actions workflow (just move it to `.github/workflows/`) with the one exception that
|
||||
`timeout-minutes` would then actually be enforced by the platform instead of being a label only
|
||||
(harmless either way).
|
||||
|
||||
Back to [README](../README.md), [AGENTS](../AGENTS.md), [SPEC](../SPEC.md).
|
||||
+20
-1
@@ -1,9 +1,14 @@
|
||||
# Research: user-level containers with overlayfs
|
||||
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
|
||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
|
||||
|
||||
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
|
||||
|
||||
Memory-safety of doos's own code (not its behavior) is checked separately with `make valgrind`
|
||||
against a fixed set of invocations; see [valgrind/README.md](../valgrind/README.md) for what it
|
||||
covers and its suppressions. Known CVEs in software doos depends on or resembles are tracked in
|
||||
[audit/README.md](../audit/README.md) (`make audit`).
|
||||
|
||||
## What works unprivileged
|
||||
|
||||
1. `clone(CLONE_NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])`, then the parent writes `setgroups=deny`, `uid_map`, `gid_map` (single entry, own uid). No setuid helpers (`newuidmap`) needed.
|
||||
@@ -57,6 +62,20 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
|
||||
| G5 | `--net host` shares abstract sockets | pasta mode | **done in 1.1.0** (`--net pasta`); `host` stays as an explicit, noted opt-in | `network.cpp` |
|
||||
| G6 | No seccomp, nested userns allowed | Capability drop + seccomp-bpf denylist | **done in 1.1.0** (`--hardening`) | `hardening.cpp` |
|
||||
| G7 | Host-owned lower dirs unwritable, `chown` of host files fails, empty `/var` | Image layer + subuid; skeleton and seeding for host mode | **done in 1.0.0** | `image.cpp`, `container.cpp` |
|
||||
| G8 | `fsutil::mkdirs` followed a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | walk the path component by component from a pinned base fd with `mkdirat`/`openat(O_NOFOLLOW\|O_DIRECTORY)`, refusing any symlink instead of following it | **done in 1.3.0**, re-measured: `make -C audit/CVE-2021-30465 test` now PASSes | `fsutil::mkdirs` in `src/util.cpp` |
|
||||
| G9 | `parseSize` left-shifted without an overflow check: `--size 16777217t` wrapped to exactly `1.0T` and was accepted instead of rejected | check `v > (UINT64_MAX >> shift)` before shifting, reject instead of wrapping | **done in 1.3.2**, reproduced before the fix, re-run after: rejected | `parseSize` in `src/util.cpp` |
|
||||
| G10 | `newuidmap`/`newgidmap`/`slirp4netns`/`pasta` were pinned to a single hardcoded `/usr/bin/...` path, unlike `curl`/`tar`/`sha256sum`/`gpg` which go through `resolveBinary()`'s state-dir-override-then-fixed-dirs search; a distro installing them elsewhere would fail even though PATH-poisoning was never possible either way | route all four through `resolveBinary()`, same as the other helpers | **done in 1.3.2** | `idmap.cpp`, `network.cpp` |
|
||||
| G11 | Alpine's `latest-releases.yaml` and Debian's `index.json` (embedded base64 manifest) are read with fixed string/byte-offset search, not a YAML/JSON library; a publisher reformatting either file breaks extraction with a thrown error (fails closed) rather than silently misreading it | none: a dependency to parse two small, publisher-controlled files is a worse trade than failing loudly when their format changes, given the project's own "fewer dependencies" rule | **accepted, not fixed**; this row exists so the trade-off is written down, not rediscovered | `Catalog::resolve` in `src/image.cpp` |
|
||||
| G12 | No way to give a container storage that does not count against `--size` (remote/network storage, e.g. a WebDAV share); every writable path was either the host overlay (capped) or a private dir (capped) | `--mount SRC:DST[:ro]`: bind-mount an already-mounted host path into the container, outside the size watchdog's scan set (`Container::usedBytes` only scans `layers/`/`private/`, never `--mount` targets). doos itself never speaks a network filesystem protocol: a plain `--mount` bind needs the host path mounted already; see G13 for WebDAV specifically | **done in 1.5.0** (`--mount`, max 8 per container, changeable, applies at next start); missing `SRC` fails the start loudly | `Container::mounts`, `mountExtra` in `src/sandbox.cpp` |
|
||||
| G13 | G12's `--mount` still needed the user to mount WebDAV themselves (e.g. `rclone mount`) before pointing `--mount` at the result; no in-box path to remote storage | `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source: doos downloads, GPG-verifies and installs a pinned `rclone` build under `$DOOS_HOME/tools/` (never the system, never PATH), then drives it on the host (on-the-fly connection string, no config file; detached double-fork daemon so the mount outlives the `attach` that created it) and bind-mounts the result exactly like a G12 bind. Deduplicated and reused by target (url+creds+vendor), not by destination | **done**, measured below under "davmount" | `src/davmount.{hpp,cpp}`, `DavMount::resolveAll` called from `Sandbox::start` |
|
||||
|
||||
### davmount (G13, measured against a local `rclone serve webdav` fixture)
|
||||
|
||||
- First use (fresh `$DOOS_HOME`, no managed rclone yet): `rclone-v1.75.2-linux-amd64.zip` (~20 MiB) downloaded, `SHA256SUMS` GPG-verified against the pinned fingerprint (`gpg` here accepts the DSA/SHA-1 signature rclone's release process still uses; it is a key-import plus `GOODSIG`/`VALIDSIG` fingerprint match, the same check as Ubuntu's image signature, not the web-of-trust warning gpg also prints), sha256 matched, unzipped, installed; the whole install plus the first mount completed in a few seconds and only ever happens once per `$DOOS_HOME`.
|
||||
- Reuse (second, unrelated container against the same target): measured at 41 ms and 45 ms for `attach -- true`, i.e. no remount cost at all; the existing FUSE mount is confirmed healthy (pid alive, mountinfo entry present, a real directory listing succeeds) and reused as is.
|
||||
- Write durability: `--vfs-cache-mode=writes` (rclone's own recommendation for WebDAV, which cannot stream) queues a write for upload after `--vfs-write-back` (shortened here to 1 s from rclone's 5 s default). Measured: a file written from inside a container appeared on the backend about 5 s after the *default* write-back elapsed in one run; with `--vfs-write-back=1s` the same write reached the backend within about 1-2 s of the writing process exiting, confirmed independent of the container's own lifetime (the container had already exited; the detached rclone daemon kept running and finished the upload on its own).
|
||||
- Failure classification, each reproduced against the same local fixture: wrong `--dav-pass` surfaced the server's `401 Unauthorized` and was reported as "authentication failed"; an unresolvable/unreachable address and a non-WebDAV endpoint (plain HTTP server, `501 Unsupported method ('PROPFIND')`) were reproduced in manual testing with the matching classifications. Every case failed within a few seconds, never the full 20 s readiness budget, because rclone mounts eagerly and only a real directory listing (not `stat` on the FUSE root, which always answers without touching the backend) forces the round trip that surfaces the error.
|
||||
- `doos davmount list`/`umount --yes`: verified showing a live target's id/vendor/url and state, dry-run output naming the path and warning it stops storage for any container still using it, and `--yes` actually unmounting (`fusermount3 -u`) and removing the managed directory.
|
||||
|
||||
### G1: memory, cpu and pids limits (cgroup v2)
|
||||
|
||||
|
||||
+6
-110
@@ -1,121 +1,17 @@
|
||||
{
|
||||
"meta": {
|
||||
"date": "2026-10-23",
|
||||
"date": "2026-10-25",
|
||||
"kernel": "7.0.0-38-generic",
|
||||
"cpus": 12,
|
||||
"runs": 10,
|
||||
"doos": "1.1.2",
|
||||
"doos": "1.5.0",
|
||||
"podman": "podman version 5.7.0",
|
||||
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
|
||||
"bubblewrap": "bubblewrap 0.11.1",
|
||||
"src_lines": 3445,
|
||||
"src_lines": 4413,
|
||||
"shared_libs": 6
|
||||
},
|
||||
"startup": [
|
||||
{
|
||||
"id": "bwrap_none",
|
||||
"label": "bubblewrap (host /usr)",
|
||||
"group": "none",
|
||||
"kind": "other",
|
||||
"ms": 8.4
|
||||
},
|
||||
{
|
||||
"id": "bwrap_net",
|
||||
"label": "bubblewrap (host /usr)",
|
||||
"group": "default",
|
||||
"kind": "other",
|
||||
"ms": 7.4
|
||||
},
|
||||
{
|
||||
"id": "doos_host_none",
|
||||
"label": "doos host mode",
|
||||
"group": "none",
|
||||
"kind": "doos",
|
||||
"ms": 18.7
|
||||
},
|
||||
{
|
||||
"id": "doos_alpine_none",
|
||||
"label": "doos Alpine image",
|
||||
"group": "none",
|
||||
"kind": "doos",
|
||||
"ms": 13.8
|
||||
},
|
||||
{
|
||||
"id": "doos_host_slirp",
|
||||
"label": "doos host mode",
|
||||
"group": "default",
|
||||
"kind": "doos",
|
||||
"ms": 56.9
|
||||
},
|
||||
{
|
||||
"id": "doos_alpine_slirp",
|
||||
"label": "doos Alpine image",
|
||||
"group": "default",
|
||||
"kind": "doos",
|
||||
"ms": 52.1
|
||||
},
|
||||
{
|
||||
"id": "doos_alpine_pasta",
|
||||
"label": "doos Alpine, pasta",
|
||||
"group": "default",
|
||||
"kind": "doos",
|
||||
"ms": 64.6
|
||||
},
|
||||
{
|
||||
"id": "podman_none",
|
||||
"label": "Podman Alpine",
|
||||
"group": "none",
|
||||
"kind": "other",
|
||||
"ms": 207.8
|
||||
},
|
||||
{
|
||||
"id": "podman_net",
|
||||
"label": "Podman Alpine",
|
||||
"group": "default",
|
||||
"kind": "other",
|
||||
"ms": 241.2
|
||||
},
|
||||
{
|
||||
"id": "docker_none",
|
||||
"label": "Docker Alpine (rootful)",
|
||||
"group": "none",
|
||||
"kind": "other",
|
||||
"ms": 343.9
|
||||
},
|
||||
{
|
||||
"id": "docker_net",
|
||||
"label": "Docker Alpine (rootful)",
|
||||
"group": "default",
|
||||
"kind": "other",
|
||||
"ms": 435.6
|
||||
}
|
||||
],
|
||||
"sizes": [
|
||||
{
|
||||
"id": "bwrap",
|
||||
"bytes": 80424
|
||||
},
|
||||
{
|
||||
"id": "crun",
|
||||
"bytes": 483392
|
||||
},
|
||||
{
|
||||
"id": "doos",
|
||||
"bytes": 565680
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"bytes": 31374336
|
||||
},
|
||||
{
|
||||
"id": "podman",
|
||||
"bytes": 43180896
|
||||
}
|
||||
],
|
||||
"cpu": {
|
||||
"wall_seconds": 4.2,
|
||||
"busy_loops": 4,
|
||||
"unlimited": 16.26,
|
||||
"half_cpu": 2.29
|
||||
}
|
||||
"startup": [{"id":"bwrap_none","label":"bubblewrap (host /usr)","group":"none","kind":"other","ms":9.2},{"id":"bwrap_net","label":"bubblewrap (host /usr)","group":"default","kind":"other","ms":8.0},{"id":"doos_host_none","label":"doos host mode","group":"none","kind":"doos","ms":16.2},{"id":"doos_alpine_none","label":"doos Alpine image","group":"none","kind":"doos","ms":14.7},{"id":"doos_host_slirp","label":"doos host mode","group":"default","kind":"doos","ms":60.0},{"id":"doos_alpine_slirp","label":"doos Alpine image","group":"default","kind":"doos","ms":56.2},{"id":"doos_alpine_pasta","label":"doos Alpine, pasta","group":"default","kind":"doos","ms":66.0},{"id":"podman_none","label":"Podman Alpine","group":"none","kind":"other","ms":216.0},{"id":"podman_net","label":"Podman Alpine","group":"default","kind":"other","ms":232.9},{"id":"docker_none","label":"Docker Alpine (rootful)","group":"none","kind":"other","ms":345.8},{"id":"docker_net","label":"Docker Alpine (rootful)","group":"default","kind":"other","ms":449.1}],
|
||||
"sizes": [{"id":"bwrap","bytes":80424},{"id":"crun","bytes":483392},{"id":"doos","bytes":704512},{"id":"docker","bytes":31374336},{"id":"podman","bytes":43180896}],
|
||||
"cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": 16.32, "half_cpu": 2.29}
|
||||
}
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@
|
||||
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
|
||||
<text class="tick" x="670.0" y="162" text-anchor="middle">20</text>
|
||||
<text class="lab" x="160" y="105.0" text-anchor="end">no limit</text>
|
||||
<path class="oth" d="M170.0,92.0 h402.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-402.5 z"><title>no limit: 16.3 CPU-seconds</title></path>
|
||||
<text class="val" x="584.5" y="105.0">16.3 CPU-s</text>
|
||||
<path class="oth" d="M170.0,92.0 h404.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-404.0 z"><title>no limit: 16.3 CPU-seconds</title></path>
|
||||
<text class="val" x="586.0" y="105.0">16.3 CPU-s</text>
|
||||
<text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text>
|
||||
<path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path>
|
||||
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||
<title id="t">Size of the main executable</title>
|
||||
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 552 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
|
||||
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 688 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
|
||||
<style>
|
||||
.bg { fill: var(--surface); }
|
||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
@@ -42,8 +42,8 @@
|
||||
<path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path>
|
||||
<text class="val" x="322.3" y="153.0">472 KiB</text>
|
||||
<text class="lab" x="160" y="183.0" text-anchor="end">doos</text>
|
||||
<path class="acc" d="M170.0,170.0 h150.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-150.4 z"><title>doos: 552 KiB</title></path>
|
||||
<text class="val" x="332.4" y="183.0">552 KiB</text>
|
||||
<path class="acc" d="M170.0,170.0 h164.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-164.5 z"><title>doos: 688 KiB</title></path>
|
||||
<text class="val" x="346.5" y="183.0">688 KiB</text>
|
||||
<text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text>
|
||||
<path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path>
|
||||
<text class="val" x="590.5" y="213.0">29.9 MiB</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
+24
-24
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||
<title id="t">Time to start a trivial command</title>
|
||||
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 14 to 65 ms; Podman: 208 to 241 ms; Docker: 344 to 436 ms; bubblewrap: 7 to 8 ms. Exact values are in the table in COMPARISON.md.</desc>
|
||||
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 15 to 66 ms; Podman: 216 to 233 ms; Docker: 346 to 449 ms; bubblewrap: 8 to 9 ms. Exact values are in the table in COMPARISON.md.</desc>
|
||||
<style>
|
||||
.bg { fill: var(--surface); }
|
||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
@@ -21,7 +21,7 @@
|
||||
</style>
|
||||
<rect class="bg" x="0" y="0" width="760" height="502" rx="8"/>
|
||||
<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>
|
||||
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-23, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
|
||||
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-25, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
|
||||
<rect class="acc" x="20" y="55" width="12" height="12" rx="2"/>
|
||||
<text class="t2" x="38" y="65">doos</text>
|
||||
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
|
||||
@@ -34,20 +34,20 @@
|
||||
<line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/>
|
||||
<line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/>
|
||||
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
|
||||
<path class="oth" d="M200.0,98.0 h3.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.9 z"><title>bubblewrap (host /usr): 8.4 ms</title></path>
|
||||
<text class="val" x="215.9" y="111.0">8.4 ms</text>
|
||||
<path class="oth" d="M200.0,98.0 h4.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-4.6 z"><title>bubblewrap (host /usr): 9.2 ms</title></path>
|
||||
<text class="val" x="216.6" y="111.0">9.2 ms</text>
|
||||
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
|
||||
<path class="acc" d="M200.0,128.0 h9.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.0 z"><title>doos Alpine image: 13.8 ms</title></path>
|
||||
<text class="val" x="221.0" y="141.0">13.8 ms</text>
|
||||
<path class="acc" d="M200.0,128.0 h9.8 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.8 z"><title>doos Alpine image: 14.7 ms</title></path>
|
||||
<text class="val" x="221.8" y="141.0">14.7 ms</text>
|
||||
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
|
||||
<path class="acc" d="M200.0,158.0 h13.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-13.6 z"><title>doos host mode: 18.7 ms</title></path>
|
||||
<text class="val" x="225.6" y="171.0">18.7 ms</text>
|
||||
<path class="acc" d="M200.0,158.0 h11.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-11.2 z"><title>doos host mode: 16.2 ms</title></path>
|
||||
<text class="val" x="223.2" y="171.0">16.2 ms</text>
|
||||
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
|
||||
<path class="oth" d="M200.0,188.0 h191.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-191.3 z"><title>Podman Alpine: 208 ms</title></path>
|
||||
<text class="val" x="403.3" y="201.0">208 ms</text>
|
||||
<path class="oth" d="M200.0,188.0 h199.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-199.0 z"><title>Podman Alpine: 216 ms</title></path>
|
||||
<text class="val" x="411.0" y="201.0">216 ms</text>
|
||||
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
|
||||
<path class="oth" d="M200.0,218.0 h319.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-319.3 z"><title>Docker Alpine (rootful): 344 ms</title></path>
|
||||
<text class="val" x="531.3" y="231.0">344 ms</text>
|
||||
<path class="oth" d="M200.0,218.0 h321.1 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-321.1 z"><title>Docker Alpine (rootful): 346 ms</title></path>
|
||||
<text class="val" x="533.1" y="231.0">346 ms</text>
|
||||
<line class="axis" x1="200" y1="90" x2="200" y2="242"/>
|
||||
<text class="tick" x="200.0" y="256" text-anchor="middle">0</text>
|
||||
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
|
||||
@@ -63,23 +63,23 @@
|
||||
<line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/>
|
||||
<line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/>
|
||||
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
|
||||
<path class="oth" d="M200.0,300.0 h3.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.0 z"><title>bubblewrap (host /usr): 7.4 ms</title></path>
|
||||
<text class="val" x="215.0" y="313.0">7.4 ms</text>
|
||||
<path class="oth" d="M200.0,300.0 h3.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.5 z"><title>bubblewrap (host /usr): 8 ms</title></path>
|
||||
<text class="val" x="215.5" y="313.0">8 ms</text>
|
||||
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
|
||||
<path class="acc" d="M200.0,330.0 h45.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-45.0 z"><title>doos Alpine image: 52.1 ms</title></path>
|
||||
<text class="val" x="257.0" y="343.0">52.1 ms</text>
|
||||
<path class="acc" d="M200.0,330.0 h48.8 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-48.8 z"><title>doos Alpine image: 56.2 ms</title></path>
|
||||
<text class="val" x="260.8" y="343.0">56.2 ms</text>
|
||||
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
|
||||
<path class="acc" d="M200.0,360.0 h49.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-49.5 z"><title>doos host mode: 56.9 ms</title></path>
|
||||
<text class="val" x="261.5" y="373.0">56.9 ms</text>
|
||||
<path class="acc" d="M200.0,360.0 h52.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-52.4 z"><title>doos host mode: 60 ms</title></path>
|
||||
<text class="val" x="264.4" y="373.0">60 ms</text>
|
||||
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
|
||||
<path class="acc" d="M200.0,390.0 h56.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-56.7 z"><title>doos Alpine, pasta: 64.6 ms</title></path>
|
||||
<text class="val" x="268.7" y="403.0">64.6 ms</text>
|
||||
<path class="acc" d="M200.0,390.0 h58.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-58.0 z"><title>doos Alpine, pasta: 66 ms</title></path>
|
||||
<text class="val" x="270.0" y="403.0">66 ms</text>
|
||||
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
|
||||
<path class="oth" d="M200.0,420.0 h222.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-222.7 z"><title>Podman Alpine: 241 ms</title></path>
|
||||
<text class="val" x="434.7" y="433.0">241 ms</text>
|
||||
<path class="oth" d="M200.0,420.0 h214.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-214.9 z"><title>Podman Alpine: 233 ms</title></path>
|
||||
<text class="val" x="426.9" y="433.0">233 ms</text>
|
||||
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
|
||||
<path class="oth" d="M200.0,450.0 h405.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-405.5 z"><title>Docker Alpine (rootful): 436 ms</title></path>
|
||||
<text class="val" x="617.5" y="463.0">436 ms</text>
|
||||
<path class="oth" d="M200.0,450.0 h418.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-418.2 z"><title>Docker Alpine (rootful): 449 ms</title></path>
|
||||
<text class="val" x="630.2" y="463.0">449 ms</text>
|
||||
<line class="axis" x1="200" y1="292" x2="200" y2="474"/>
|
||||
<text class="tick" x="200.0" y="488" text-anchor="middle">0</text>
|
||||
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,52 @@
|
||||
# doos: Security Review
|
||||
|
||||
Date: 2026-10-23 | Reviewer: tai | Scope: `src/*.cpp/*.hpp`, `CMakeLists.txt`, `Makefile`, `apparmor/doos`, `tests/run.sh`, `docs/*`, `SPEC.md`
|
||||
Build: C++17 CMake project ("doos" container/sandbox tool using user namespaces, cgroup v2, seccomp-bpf, pasta/slirp4netns networking, overlayfs).
|
||||
|
||||
## Summary
|
||||
Rootless container runtime with generally sound isolation primitives (user ns + idmap, pivot_root, cap-drop, seccomp denylist, no-new-privs, cgroup limits). Main risks: privileged helpers trusted via PATH (`pasta`, `slirp4netns`, `newuidmap/newgidmap`), TOCTOU-prone fs setup, permissive `--hardening relaxed` and `--net host` modes, and missing supply-chain/hardening controls (no SBOM/signing, no CI fuzz/scan, trivial AppArmor profile).
|
||||
|
||||
## Findings (severity-ordered)
|
||||
1. **[MEDIUM] PATH-based exec of network helpers**: `src/network.cpp` execs `pasta`/`slirp4netns` resolved via PATH; a poisoned PATH in attach path = code exec as the user. Mitigate with absolute paths + allowlist env scrub.
|
||||
2. **[MEDIUM] Symlink-following fs staging (TOCTOU)**: `src/sandbox.cpp:133-139` mirrors host `/etc` entries with `symlink_status`/`read_symlink` then recreates; bind source handling without `O_NOFOLLOW`/fd-relative ops invites race/escape. Use `openat2(RESOLVE_NO_SYMLINKS)` or pin fds.
|
||||
3. **[MEDIUM] `relaxed` mode silently drops most controls**: per `cli.cpp:121`, relaxed = no cap-drop, no seccomp, full `/etc`. Users likely won't grasp severity. Require explicit `--i-understand` flag + warning.
|
||||
4. **[MEDIUM] No integrity for images/layers**: `src/image.cpp` untars with numeric-owner handling but no digest/signature verification. Tampered tar = full container compromise. Add sha256 manifest check.
|
||||
5. **[LOW] Temp overlay upperdir via `mkdtemp` in shared TMPDIR**: `src/image.cpp:52` uses `temp_directory_path()` (often `/tmp`, sticky but world-visible); predictable staging + 0700 chmod after the fact. Prefer per-user state dir with `O_EXCL`.
|
||||
6. **[LOW] `sscanf` parsing of cgroup stat**: `src/container.cpp:231` unchecked return; malformed file → stale values. Check return value.
|
||||
7. **[LOW] AppArmor profile is a stub**: `apparmor/doos` (12 lines) grants far less than runtime needs / isn't enforced by code. Either complete or remove to avoid false assurance.
|
||||
8. **[INFO] No seccomp allowlist, denylist only**: `hardening.cpp` blocks keyring/bpf/perf/io_uring/mount/kexec/module-load; reasonable, but bypass surface remains (ptrace? userfaultfd covered). Document residual attack surface.
|
||||
9. **[INFO] No network egress policy**: pasta/slirp give full user-mode egress; no DNS/TLS filtering. Expected for dev tool; document.
|
||||
|
||||
### What is done well
|
||||
- User-namespace-first, no setuid binary; `newuidmap` delegation preferred.
|
||||
- `NO_NEW_PRIVS` + capability bounding set to Docker-default keep-list.
|
||||
- `/proc`/`/sys` remounts read-only, masked paths (`kcore`, `keys`), minimal `/dev`.
|
||||
- Cgroup v2 `main/` vs `shell/` split with freeze + rescue path is thoughtful anti-ransom design.
|
||||
- `pivot_root` (not just chroot), `umask`, `0700/0640` perms on state.
|
||||
|
||||
## Grades (0–10, 10=excellent)
|
||||
| Category | Score | Rationale |
|
||||
|---|---|---|
|
||||
| Isolation & sandboxing | 7.5 | userns+pivot+cap-drop+seccomp+nonewprivs solid; weakened by relaxed mode, helper PATH trust |
|
||||
| Filesystem security | 6.5 | Good perms/skeleton; symlink TOCTOU + /tmp staging + no dm-verity/readonly-root |
|
||||
| Privilege & identity mgmt | 7.0 | No setuid, subuid mapping correct; host-uid file visibility nuance documented but subtle |
|
||||
| Network security | 6.0 | User-mode net avoids root; no egress/filtering, PATH-exec helpers, `--net host` footgun |
|
||||
| Resource controls (DoS) | 8.0 | cgroup v2 mem/pids/cpu + freeze model; best-effort when undelegated (fail-open must warn) |
|
||||
| Input validation & parsing | 6.5 | CLI fixed-value flags good; tar/symlink handling + sscanf without strict checks |
|
||||
| Secrets & state handling | 7.0 | State under DOOS_HOME with 0700; env (HOME/PATH) trusted too broadly |
|
||||
| Supply chain & build | 5.0 | No SBOM, signing, pinning, CI SAST/fuzz; CMake clean but no `-D_FORTIFY`/`-fstack-protector` audit |
|
||||
| Observability & audit | 5.5 | `doos list` table ok; no security event log, no seccomp-deny telemetry |
|
||||
| Defaults & secure-by-default | 6.0 | Default mode good; relaxed/host modes one flag away with weak friction |
|
||||
| **Overall** | **6.5** | Solid rootless dev-sandbox; fix PATH-exec, TOCTOU, image integrity before untrusted workloads |
|
||||
|
||||
## Recommendations (prioritized)
|
||||
1. Absolute-path helpers + minimal sanitized env for all `execvp`.
|
||||
2. `openat2` / fd-pinned fs staging; fail closed on symlink races.
|
||||
3. Image digest verification (sha256 manifest) before untar.
|
||||
4. Gate `relaxed`/`--net host` behind explicit confirmation + log warning.
|
||||
5. Harden build flags (`-Wall -Wextra -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -Wl,-z,RelRO,Now`), add SBOM + CI (CodeQL/Semgrep/Trivy).
|
||||
6. Check `sscanf` returns; move temp staging under state dir.
|
||||
7. Finish or drop AppArmor profile; add seccomp-deny + OOM audit logging.
|
||||
|
||||
## Verdict
|
||||
Fit for trusted-dev use. **Not yet recommended** for running untrusted images/payloads until (1), (2), (3) are fixed.
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
# doos: full review
|
||||
|
||||
Date: 2026-10-24 | Scope: all of `src/` (3,558 lines, read in full), `CMakeLists.txt`, `Makefile`,
|
||||
`scripts/*.sh`, `tests/run.sh`, `audit/`, `valgrind/`, and every document (`README.md`, `SPEC.md`,
|
||||
`docs/ARCHITECTURE.md`, `docs/RESEARCH.md`, `COMPARISON.md`, `AGENTS.md`, `CHANGELOG.md`).
|
||||
Method: read every source file top to bottom (not a diff), ran the real e2e suite, the CVE audit,
|
||||
and valgrind against the current binary, and tested two inputs by hand that looked suspicious while
|
||||
reading. This is a second, independent pass; [`review.md`](review.md) is an earlier review whose
|
||||
findings are checked against the current code, not reused.
|
||||
|
||||
**State at review time:** `v1.3.1`, working tree clean, `tests/run.sh` 88/88, `make audit` 18/18,
|
||||
`make valgrind` 6/6 (see those tools for what they do and do not cover).
|
||||
|
||||
## 1. Summary
|
||||
|
||||
doos is a small, coherent, unusually well-specified codebase for what it is: a single C++20 binary
|
||||
that builds a user-namespace container by hand (no runc, no libcontainer, no OCI anything) and
|
||||
backs it with overlayfs, cgroup v2 and a seccomp denylist. The code reads like it was written by one
|
||||
disciplined author following one set of rules consistently: every constant lives in `spec.hpp`, every
|
||||
destructive path is capped, every external process is run from a fixed path, every error throws with
|
||||
a message that says what to do about it. The project-level discipline (docs that state gaps next to
|
||||
the code that closes them, a changelog per release, a `make audit`/`make valgrind` pair that produces
|
||||
actual evidence instead of claims) is better than most projects an order of magnitude larger.
|
||||
|
||||
The flip side of "one author, one style" is that nothing here has been read by a second pair of eyes
|
||||
until review.md and this review. I found one confirmed, reproducible bug (an integer overflow that
|
||||
silently accepts a nonsensical `--size`) and a handful of smaller inconsistencies and gaps, listed
|
||||
below. Nothing I found is a new security hole in the model doos is actually built on; the shared-kernel,
|
||||
denylist-seccomp, unaudited-code risks are already disclosed in `COMPARISON.md` section 6 and
|
||||
`docs/RESEARCH.md`, and I did not find reason to soften or harden that disclosure.
|
||||
|
||||
**Resolution (1.3.2, same day):** 2.1, 2.2 and 2.4 are fixed (see `CHANGELOG.md`); 2.3 is logged as
|
||||
an accepted trade-off (gap G11 in `docs/RESEARCH.md`) rather than fixed; 2.5's CI half is resolved
|
||||
by `.gitea/workflows/ci.yml` and `docs/CI.md`, and its LICENSE half by the new `LICENSE` file. The
|
||||
findings below are left exactly as first written, for the record of what was found and how.
|
||||
|
||||
## 2. Findings
|
||||
|
||||
Ordered most to least severe. Each is something I read or reproduced, not a hypothetical.
|
||||
|
||||
### 2.1 [MEDIUM] `parseSize` silently overflows on an absurd input, instead of rejecting it
|
||||
|
||||
`src/util.cpp:54-71`. The function accepts up to 15 digits, then left-shifts by up to 40 bits with no
|
||||
overflow check. `--size 16777217t` (16.7 million exabytes, obvious garbage) wraps around 64-bit
|
||||
arithmetic to exactly `2^40`, which is `1.0T`, which is a *valid* size, so it is accepted silently:
|
||||
|
||||
```
|
||||
$ doos get_or_create --size 16777217t sizetest2
|
||||
created sizetest2
|
||||
$ doos list
|
||||
NAME STATE NET IMAGE SIZE AGE
|
||||
sizetest2 stopped slirp host 1.0T 0s
|
||||
```
|
||||
|
||||
Reproduced against `build/doos` at the time of this review. The same shift pattern exists for `k`/`m`/`g`
|
||||
too, just with smaller multipliers that need more digits to wrap; `t` wraps with an 8-digit input. Every
|
||||
caller does bound-check the *result*, which is why most overflowing inputs are still rejected (the
|
||||
earlier example, `16777217t`+1 lands outside range and throws): it is specifically the inputs that
|
||||
wrap into the valid `[64m, 1t]` window that go through unnoticed. Fix: check `v > (kMaxSizeBytes >>
|
||||
shift)` before shifting, or reject more than 4-5 digits before a `t`/`g` suffix.
|
||||
|
||||
### 2.2 [LOW] Two different "no PATH lookup" strategies for external helpers, not one
|
||||
|
||||
`spec.hpp:52-53,75-76` hardcode `/usr/bin/newuidmap`, `/usr/bin/newgidmap`, `/usr/bin/slirp4netns`,
|
||||
`/usr/bin/pasta` as single absolute paths. `resolveBinary()` (`src/util.cpp:397-417`) instead checks a
|
||||
state-dir override then a list of six system directories, and is used for `curl`, `tar`, `sha256sum`
|
||||
and `gpg`. Both avoid a `PATH`-poisoning attack (the actual goal, and both reach it), but the first
|
||||
group breaks if a distribution installs those four tools somewhere other than exactly `/usr/bin` (for
|
||||
example Fedora's `/usr/sbin` for some setuid helpers, or a non-standard prefix), while the second group
|
||||
would still find them. This is not a security gap, it is an inconsistency that will show up as a
|
||||
confusing "helper not found" on a distro the project has not been tested on, where the real fix (run
|
||||
`resolveBinary()` for all five, or document the four as intentionally pinned) is a few lines either way.
|
||||
|
||||
### 2.3 [LOW] Catalog YAML/JSON parsing is hand-rolled string search, not a parser
|
||||
|
||||
`src/image.cpp:160-173` (Alpine's `latest-releases.yaml`) and `src/image.cpp:192-204` (Debian's
|
||||
`index.json`, including a base64-embedded manifest) are read with `line.find(...)` and fixed byte
|
||||
offsets, not a YAML/JSON library. This keeps the dependency count at zero, which fits the project's
|
||||
"fewer moving parts" rule, and it is already tested against the real Alpine and Debian endpoints in
|
||||
`tests/run.sh`. The risk is narrow but real: if either publisher reformats their file (reorders keys,
|
||||
changes indentation, wraps a line), the parser fails closed with a thrown error (good: `docs/RESEARCH.md`
|
||||
already calls this an accepted trade-off for Debian specifically) rather than silently extracting the
|
||||
wrong artifact. Worth a one-line gap entry next to the code, same as the project's own convention asks.
|
||||
|
||||
### 2.4 [LOW] `Sandbox::join`'s ordering is a correctness invariant that is not stated in code
|
||||
|
||||
`src/sandbox.cpp:521-581`. Joining a running container does six `setns()` calls in a plain (not yet
|
||||
seccomp-filtered) forked process, then forks again and *only there* calls `applyLimitsAndEnv`, which
|
||||
installs the seccomp filter that denies `setns` (`src/hardening.cpp:184-186`). This is correct and
|
||||
I could not find a bug in it, but the correctness depends entirely on hardening being applied after
|
||||
the `setns` loop and in the right (inner) fork, a constraint that lives only in the order of statements,
|
||||
not in a comment or an assertion. A future edit that moves `applyLimitsAndEnv` earlier "to simplify"
|
||||
would silently break every second `attach`, and the e2e suite's "second attach joins" case would need
|
||||
to be the thing that catches it rather than a comment telling the next editor not to do that.
|
||||
|
||||
### 2.5 [LOW] No CI, no license file
|
||||
|
||||
`find . -iname "*.yml"` turns up nothing; there is no `.github/workflows`, so the 88-check e2e suite,
|
||||
`make audit` and `make valgrind` only run when someone remembers to run them by hand. There is also no
|
||||
`LICENSE` file, which for a "commercial and scientific" project (the user's own standing rule) means
|
||||
the legal default is "all rights reserved" by anyone who is not the author, worth a deliberate choice
|
||||
rather than an omission.
|
||||
|
||||
### 2.6 [INFO] Everything else I checked was either already documented or held up
|
||||
|
||||
I specifically went looking for bugs in the capability-drop/seccomp-install ordering relative to
|
||||
`execve`, in the overlayfs mount option construction, in the `--size`/`--memory`/`--pids`/`--cpus`
|
||||
range validation (other than 2.1), in the container/image name validators against path traversal, in
|
||||
`Store::remove`'s delete-outside-the-store guard, and in the symlink handling added for the PATH and
|
||||
TOCTOU fixes (`src/util.cpp:329-354`, `src/sandbox.cpp:130-153`). All of it matched what the comments
|
||||
claim, and the specific "mkdirs follows a symlink" class of bug this project itself found via
|
||||
`audit/CVE-2021-30465` is closed the right way (per-component `openat(O_NOFOLLOW)`), not patched over.
|
||||
|
||||
## 3. Grades
|
||||
|
||||
| Category | Grade | Why |
|
||||
|---|---|---|
|
||||
| Architecture & code organization | 8.5/10 | One class per responsibility, matches `docs/ARCHITECTURE.md` exactly; every constant in one file; consistent error style throughout 3,558 lines. Loses points only for 2.4's unstated invariant. |
|
||||
| Correctness & input validation | 7/10 | Careful everywhere except one real bug (2.1). CLI parsing, name validation, bounds checks on memory/pids/cpus are all solid; size parsing is the one spot that was not. |
|
||||
| Isolation design (namespaces, overlayfs, mounts) | 9/10 | TOCTOU-safe symlink handling, `O_NOFOLLOW` directory creation, correct remount-flag preservation, correct setns/seccomp ordering. This is the part of the codebase that most clearly got the most attention, and it shows. |
|
||||
| Resource controls (cgroup, size cap) | 8.5/10 | Adaptive scan-duty throttling, freeze/thaw hysteresis, whole-container OOM kill, hard caps on scan size and tree depth. Honestly scoped: documented as advisory without cgroup v2 delegation rather than pretending to enforce. |
|
||||
| Supply chain (image verification) | 8/10 | Real signature verification against pinned fingerprints for two of three catalog sources, sha256 for all three, URL inputs refused without a hash. Loses points for the hand-rolled parsers (2.3) and for Debian having no publisher signature (disclosed, not doos's fault, but still the weakest link). |
|
||||
| Hardening (capabilities, seccomp) | 8/10 | Matches Docker's default capability mask (measured, not assumed), denylist seccomp with the known evasions (x32, clone3, nested userns) explicitly closed. A denylist is inherently weaker than an allowlist by design, which the project says itself in `COMPARISON.md`; grade reflects execution of the chosen design, not the design choice. |
|
||||
| Testing & verification tooling | 8.5/10 | 88 end-to-end checks through the real path (real overlayfs, real downloads, real cgroups), plus `audit/` (18 named CVEs with working reproducers) and `valgrind/` (6 checks, suppressions individually justified) are well above what a project this size usually has. One machine, one architecture, no CI (2.5) is the honest ceiling. |
|
||||
| Documentation | 9/10 | Seven cross-linked documents that state what exists today, with gaps listed next to the code that closes them; generated numbers and charts instead of hand-typed ones; a changelog that actually matches releases. Among the best-documented small C++ projects I have reviewed. |
|
||||
| Build & release automation | 8.5/10 | `scripts/release.sh` bumps, builds, tests, checks docs, commits and tags in one gated command and aborts on any failure; hardened compiler/linker flags. Loses half a point for no CI to run any of it automatically. |
|
||||
| CLI/UX | 7.5/10 | Clear, narrow-terminal-friendly usage text; sensible fixed-vs-changeable option model; good error messages that say what to do next. `--i-understand` as a deliberate confirmation gate for reduced sandboxing is a good pattern. |
|
||||
| Project hygiene | 6/10 | No LICENSE, no CI (2.5); otherwise clean (`.gitignore` is thorough, no stray build artifacts tracked, commit messages are consistent and attributed). |
|
||||
|
||||
**Overall: 8/10.** A small, honest, carefully built tool with one real bug found by testing it rather
|
||||
than reading it, and a short, specific list of things worth fixing. Nothing found here changes the
|
||||
conclusion already in `COMPARISON.md`: fine for your own tools, not a substitute for an audited,
|
||||
multi-tenant-hardened runtime.
|
||||
|
||||
## 4. Recommended order of fixes
|
||||
|
||||
1. Fix the `parseSize` overflow (2.1): cheapest fix, only confirmed bug, trivial to test.
|
||||
2. Add a one-line comment above `applyLimitsAndEnv`'s two call sites in `sandbox.cpp` stating the
|
||||
ordering invariant from 2.4, so the next edit does not have to rediscover it the hard way.
|
||||
3. Decide and document the helper-resolution split (2.2): either move the four pinned paths through
|
||||
`resolveBinary()`, or add one line to `SPEC.md` saying why they are intentionally narrower.
|
||||
4. Add a LICENSE file (2.5); pick whichever the user already uses elsewhere.
|
||||
5. CI is the only item here that is genuinely more than a few lines of work (namespaces, cgroup v2
|
||||
delegation and AppArmor all need to exist on the runner); worth a separate, explicit decision
|
||||
rather than a drive-by addition.
|
||||
|
||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) ·
|
||||
[RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) ·
|
||||
[CHANGELOG](CHANGELOG.md) · [audit](audit/README.md) · [valgrind](valgrind/README.md) ·
|
||||
[review.md](review.md) (earlier security-only review)
|
||||
@@ -4,7 +4,7 @@
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md)
|
||||
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md docs/CI.md)
|
||||
bad=0
|
||||
fail() { echo "docs: $*"; bad=$((bad+1)); }
|
||||
|
||||
@@ -12,7 +12,11 @@ fail() { echo "docs: $*"; bad=$((bad+1)); }
|
||||
while IFS= read -r f; do
|
||||
f=${f#./}
|
||||
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
|
||||
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md') # prompt.md: the original task statement, not documentation
|
||||
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './review2.md' -not -path './valgrind/*')
|
||||
# prompt.md: the original task statement, not documentation.
|
||||
# audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the
|
||||
# valgrind setup); linked from the main docs but not forced into their full cross-link mesh.
|
||||
# review.md, review2.md: standalone one-off reviews, not part of the maintained doc set.
|
||||
|
||||
for f in "${DOCS[@]}"; do
|
||||
[ -f "$f" ] || { fail "$f is missing"; continue; }
|
||||
|
||||
+1
-1
@@ -32,6 +32,6 @@ tests/run.sh
|
||||
git add -A
|
||||
git commit -m "$subject
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
git tag -a "v$new" -m "doos $new"
|
||||
echo "released v$new"
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs a fixed, named set of doos invocations under valgrind's memcheck, using
|
||||
# valgrind/doos.supp to silence the known, explained allocation sites (see that file's
|
||||
# comments), and fails only on a real, unsuppressed memory error or leak. Does not rebuild;
|
||||
# run `make build` (or just `make valgrind`, which depends on it) first.
|
||||
#
|
||||
# Usage: scripts/valgrind.sh [path/to/doos]
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOOS="${1:-build/doos}"
|
||||
SUPP="$(pwd)/valgrind/doos.supp"
|
||||
REPORTS="build/valgrind-reports"
|
||||
TIMEOUT=60
|
||||
|
||||
command -v valgrind >/dev/null || { echo "valgrind: not installed"; exit 1; }
|
||||
[ -x "$DOOS" ] || { echo "valgrind: no doos binary at '$DOOS' (run 'make build' first)"; exit 1; }
|
||||
[ -f "$SUPP" ] || { echo "valgrind: suppressions file missing: $SUPP"; exit 1; }
|
||||
|
||||
rm -rf "$REPORTS"
|
||||
mkdir -p "$REPORTS"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export DOOS_HOME="$TMP/home"
|
||||
|
||||
fails=0
|
||||
checked=0
|
||||
|
||||
run() { # run NAME -- DOOS_ARGS...
|
||||
local name="$1"; shift
|
||||
shift # drop the --
|
||||
checked=$((checked + 1))
|
||||
local log="$REPORTS/$name.log"
|
||||
timeout "$TIMEOUT" valgrind \
|
||||
--leak-check=full --show-leak-kinds=all --track-origins=yes \
|
||||
--suppressions="$SUPP" --error-exitcode=99 \
|
||||
--log-file="$log" \
|
||||
"$DOOS" "$@" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
if [ "$rc" = 99 ]; then
|
||||
echo "FAIL $name: valgrind reported an unsuppressed error or leak, see $log"
|
||||
fails=$((fails + 1))
|
||||
elif [ "$rc" = 124 ]; then
|
||||
echo "FAIL $name: timed out after ${TIMEOUT}s under valgrind, see $log"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "ok $name (doos exit=$rc; valgrind found nothing to report), see $log"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fixed, named set: the four CLI commands SPEC.md documents, plus a second attach to exercise
|
||||
# the join-a-running-container path. No image download here (network + extraction add their
|
||||
# own long-running C library code, better covered once by a dedicated image-mode run; see
|
||||
# valgrind/README.md for how to add one).
|
||||
run list -- list
|
||||
run get_or_create -- get_or_create --net none vgcheck
|
||||
run attach_first -- attach --net none vgcheck -- true
|
||||
run attach_join -- attach --net none vgcheck -- true
|
||||
run image_list -- image list
|
||||
run delete -- delete --yes vgcheck
|
||||
|
||||
echo "valgrind: $checked checked, $fails failed, reports in $REPORTS/"
|
||||
[ "$fails" -eq 0 ]
|
||||
+165
-4
@@ -7,6 +7,7 @@
|
||||
#include <optional>
|
||||
|
||||
#include "container.hpp"
|
||||
#include "davmount.hpp"
|
||||
#include "sandbox.hpp"
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
@@ -18,11 +19,92 @@ namespace {
|
||||
std::uint32_t parseCount(const std::string& text);
|
||||
std::uint32_t parseCpus(const std::string& text);
|
||||
|
||||
// "SRC:DST" or "SRC:DST:ro"/"SRC:DST:rw" (paths themselves may not contain ':').
|
||||
MountSpec parseMountFlag(const std::string& text) {
|
||||
std::vector<std::string> parts;
|
||||
std::string cur;
|
||||
for (char ch : text) {
|
||||
if (ch == ':') { parts.push_back(cur); cur.clear(); }
|
||||
else cur += ch;
|
||||
}
|
||||
parts.push_back(cur);
|
||||
if (parts.size() < 2 || parts.size() > 3) {
|
||||
throw std::runtime_error("--mount needs SRC:DST or SRC:DST:ro (got '" + text + "')");
|
||||
}
|
||||
MountSpec m;
|
||||
m.src = parts[0];
|
||||
m.dst = parts[1];
|
||||
if (parts.size() == 3) {
|
||||
if (parts[2] != "ro" && parts[2] != "rw") {
|
||||
throw std::runtime_error("--mount's third field must be 'ro' or 'rw' (got '" + parts[2] + "')");
|
||||
}
|
||||
m.readOnly = parts[2] == "ro";
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
bool hasDavScheme(const std::string& s) {
|
||||
for (const char* p : {"dav://", "davs://", "http://", "https://"}) {
|
||||
if (s.rfind(p, 0) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// "DST" or "DST:ro"/"DST:rw" (the URL itself is a separate token, so this never sees a scheme's own ':').
|
||||
void applyDstSuffix(MountSpec& m, const std::string& distSpec) {
|
||||
auto c = distSpec.find(':');
|
||||
if (c == std::string::npos) { m.dst = distSpec; return; }
|
||||
m.dst = distSpec.substr(0, c);
|
||||
std::string suf = distSpec.substr(c + 1);
|
||||
if (suf != "ro" && suf != "rw") {
|
||||
throw std::runtime_error("mount destination suffix must be ':ro' or ':rw' (got '" + distSpec + "')");
|
||||
}
|
||||
m.readOnly = suf == "ro";
|
||||
}
|
||||
|
||||
// URL contains ':' (scheme, maybe a port), so it cannot share a single colon-delimited token with
|
||||
// DST the way the classic bind-mount form does: --mount/--davmount takes the URL and DST as two
|
||||
// separate argv tokens instead. Credentials embedded as user[:pass]@host are pulled out of the URL
|
||||
// (a password containing '@' will not round-trip through this; use --dav-user/--dav-pass instead).
|
||||
MountSpec parseDavMountFlag(const std::string& url, const std::string& distSpec) {
|
||||
MountSpec m;
|
||||
m.kind = MountKind::WebDav;
|
||||
m.davVendor = "other";
|
||||
std::string rest;
|
||||
if (url.rfind("dav://", 0) == 0) rest = "http://" + url.substr(6);
|
||||
else if (url.rfind("davs://", 0) == 0) rest = "https://" + url.substr(7);
|
||||
else rest = url; // http:// or https://, used as given
|
||||
|
||||
std::size_t schemeEnd = rest.find("://");
|
||||
schemeEnd = schemeEnd == std::string::npos ? 0 : schemeEnd + 3;
|
||||
std::size_t pathStart = rest.find('/', schemeEnd);
|
||||
std::string authority = rest.substr(schemeEnd, pathStart == std::string::npos ? std::string::npos : pathStart - schemeEnd);
|
||||
std::size_t at = authority.rfind('@');
|
||||
if (at != std::string::npos) {
|
||||
std::string userinfo = authority.substr(0, at), hostport = authority.substr(at + 1);
|
||||
std::size_t colon = userinfo.find(':');
|
||||
if (colon == std::string::npos) m.davUser = userinfo;
|
||||
else { m.davUser = userinfo.substr(0, colon); m.davPass = userinfo.substr(colon + 1); }
|
||||
rest = rest.substr(0, schemeEnd) + hostport + (pathStart == std::string::npos ? "" : rest.substr(pathStart));
|
||||
}
|
||||
m.src = rest;
|
||||
applyDstSuffix(m, distSpec);
|
||||
return m;
|
||||
}
|
||||
|
||||
MountSpec& lastDavMount(Options& opts, const char* flag) {
|
||||
if (!opts.mounts || opts.mounts->empty() || opts.mounts->back().kind != MountKind::WebDav) {
|
||||
throw std::runtime_error(std::string(flag) + " must follow a --mount dav://... (or --davmount) entry");
|
||||
}
|
||||
return opts.mounts->back();
|
||||
}
|
||||
|
||||
// Leading --options, then the first positional, then everything else untouched.
|
||||
struct Parsed {
|
||||
Options opts;
|
||||
std::optional<std::string> sha256;
|
||||
bool yes = false;
|
||||
bool iUnderstand = false; // --i-understand, required for relaxed / --net host
|
||||
std::vector<std::string> positional; // up to maxPositional, the rest lands in tail
|
||||
std::vector<std::string> tail;
|
||||
};
|
||||
@@ -58,7 +140,29 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
||||
p.opts.relaxed = (v == "relaxed");
|
||||
}
|
||||
else if (allow.container && (is(a, "--mount") || is(a, "--davmount"))) {
|
||||
bool explicitDav = is(a, "--davmount");
|
||||
std::string flagName = explicitDav ? "--davmount" : "--mount";
|
||||
std::string first = value(i, flagName);
|
||||
if (!p.opts.mounts) p.opts.mounts = std::vector<MountSpec>{};
|
||||
if (p.opts.mounts->size() >= spec::kMaxMounts) {
|
||||
throw std::runtime_error("too many --mount/--davmount flags (max " + std::to_string(spec::kMaxMounts) + ")");
|
||||
}
|
||||
if (hasDavScheme(first)) {
|
||||
if (i + 1 >= rest.size()) throw std::runtime_error(flagName + " needs a destination after the URL");
|
||||
p.opts.mounts->push_back(parseDavMountFlag(first, rest[++i]));
|
||||
} else if (explicitDav) {
|
||||
throw std::runtime_error("--davmount needs a dav://, davs://, http:// or https:// URL (got '" + first + "')");
|
||||
} else {
|
||||
p.opts.mounts->push_back(parseMountFlag(first));
|
||||
}
|
||||
}
|
||||
else if (allow.container && is(a, "--dav-user")) lastDavMount(p.opts, "--dav-user").davUser = value(i, "--dav-user");
|
||||
else if (allow.container && is(a, "--dav-pass")) lastDavMount(p.opts, "--dav-pass").davPass = value(i, "--dav-pass");
|
||||
else if (allow.container && is(a, "--dav-vendor")) lastDavMount(p.opts, "--dav-vendor").davVendor = value(i, "--dav-vendor");
|
||||
else if (allow.container && a == "--dav-insecure") lastDavMount(p.opts, "--dav-insecure").davInsecure = true;
|
||||
else if (allow.yes && a == "--yes") p.yes = true;
|
||||
else if (a == "--i-understand") p.iUnderstand = true;
|
||||
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
|
||||
else if (a.size() > 1 && a[0] == '-') throw std::runtime_error("unknown option " + a);
|
||||
else if (p.positional.size() < wantPositional) p.positional.push_back(a);
|
||||
@@ -70,6 +174,21 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
return p;
|
||||
}
|
||||
|
||||
void hardenNotice(const Container& c, const Parsed& p) {
|
||||
bool relaxed = p.opts.relaxed.value_or(c.relaxed());
|
||||
bool hostNet = (p.opts.net.has_value() ? *p.opts.net : c.net()) == NetMode::Host;
|
||||
if ((relaxed || hostNet) && !p.iUnderstand) {
|
||||
throw std::runtime_error("--hardening relaxed and --net host drop capability drop, seccomp filtering and/or "
|
||||
"network isolation; pass --i-understand to confirm you accept the reduced sandboxing");
|
||||
}
|
||||
if (relaxed || hostNet) {
|
||||
std::fprintf(stderr, "doos: warning: reduced sandboxing (%s%s%s)\n",
|
||||
relaxed ? "no capability drop, no seccomp, full /etc" : "",
|
||||
(relaxed && hostNet) ? "; " : "",
|
||||
hostNet ? "shares the host network namespace" : "");
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t parseCount(const std::string& text) {
|
||||
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid number '" + text + "'");
|
||||
@@ -112,21 +231,34 @@ void Cli::usage(std::FILE* out) {
|
||||
" doos image list\n"
|
||||
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
|
||||
" doos image delete [--yes] NAME\n"
|
||||
" doos davmount list\n"
|
||||
" doos davmount umount [--yes] ID\n"
|
||||
" doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n"
|
||||
"\n"
|
||||
"options:\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand) [fixed]\n"
|
||||
" --net slirp|pasta|host|none network, default slirp (pasta: host-like, private sockets) [fixed]\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
||||
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [fixed]\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
||||
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [fixed]\n"
|
||||
" --i-understand confirm you accept reduced sandboxing when using relaxed or --net host\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
||||
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
||||
" --pids N process cap, 16..4194304, default 4096\n"
|
||||
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\n"
|
||||
" size, memory, pids and cpus can be changed on any later call; they apply at the next start.\n"
|
||||
" --mount SRC:DST[:ro] bind-mount host path SRC at DST inside the container, repeatable (max 8)\n"
|
||||
" --mount URL DST[:ro] URL starting dav://, davs://, http:// or https:// auto-mounts WebDAV via a\n"
|
||||
" managed rclone (downloaded and verified once); same as --davmount\n"
|
||||
" --davmount URL DST[:ro] explicit WebDAV mount: URL DST[:ro], credentials as user[:pass]@host or below\n"
|
||||
" --dav-user USER WebDAV username for the --mount/--davmount entry just given\n"
|
||||
" --dav-pass PASS WebDAV password for it (use this instead of embedding '@'/':' in the URL)\n"
|
||||
" --dav-vendor NAME other|nextcloud|owncloud|sharepoint|sharepoint-ntlm, default other\n"
|
||||
" --dav-insecure skip TLS certificate verification for that WebDAV target\n"
|
||||
" size, memory, pids, cpus and mount can be changed on any later call; they apply at the next start.\n"
|
||||
" mount replaces the whole mount list; a bind SRC must exist and be a directory when the container starts.\n"
|
||||
"\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
|
||||
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
|
||||
"davmount umount stops storage for any container still using that target; see 'doos davmount list' for its ID.\n"
|
||||
"State lives in $DOOS_HOME or ~/.local/share/doos.\n",
|
||||
out);
|
||||
}
|
||||
@@ -145,6 +277,7 @@ int Cli::run() {
|
||||
if (cmd == "attach") return cmdAttach(rest);
|
||||
if (cmd == "delete") return cmdDelete(rest);
|
||||
if (cmd == "image") return cmdImage(rest);
|
||||
if (cmd == "davmount") return cmdDavmount(rest);
|
||||
if (cmd == "apparmor") return cmdApparmor();
|
||||
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
|
||||
usage(stderr);
|
||||
@@ -169,6 +302,7 @@ int Cli::cmdGetOrCreate(std::vector<std::string> rest) {
|
||||
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
|
||||
Store store = Store::fromEnvironment();
|
||||
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
||||
hardenNotice(c, p);
|
||||
std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str());
|
||||
return 0;
|
||||
}
|
||||
@@ -178,6 +312,7 @@ int Cli::cmdAttach(std::vector<std::string> rest) {
|
||||
Store store = Store::fromEnvironment();
|
||||
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
||||
(void)created;
|
||||
hardenNotice(c, p);
|
||||
return Sandbox(std::move(c), std::move(p.tail)).run();
|
||||
}
|
||||
|
||||
@@ -269,4 +404,30 @@ int Cli::cmdImageDelete(std::vector<std::string> rest) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Cli::cmdDavmount(std::vector<std::string> rest) {
|
||||
if (rest.empty()) throw std::runtime_error("davmount needs a subcommand: list, umount");
|
||||
std::string sub = rest[0];
|
||||
std::vector<std::string> tail(rest.begin() + 1, rest.end());
|
||||
if (sub == "list") return cmdDavmountList();
|
||||
if (sub == "umount") return cmdDavmountUmount(tail);
|
||||
throw std::runtime_error("unknown davmount subcommand '" + sub + "' (list, umount)");
|
||||
}
|
||||
|
||||
int Cli::cmdDavmountList() {
|
||||
auto all = DavMount::list();
|
||||
if (all.empty()) { std::puts("no davmounts"); return 0; }
|
||||
std::printf("%-*s %-7s %-10s %s\n", 16, "ID", "STATE", "VENDOR", "URL");
|
||||
for (const auto& s : all) {
|
||||
std::printf("%-*s %-7s %-10s %s\n", 16, s.id.c_str(), s.mounted ? "mounted" : "stale", s.vendor.c_str(), s.url.c_str());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Cli::cmdDavmountUmount(std::vector<std::string> rest) {
|
||||
Parsed p = parse(rest, {false, true, false}, 1);
|
||||
if (!p.tail.empty()) throw std::runtime_error("davmount umount takes only an ID");
|
||||
std::puts(DavMount::umount(p.positional[0], p.yes).c_str());
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
@@ -20,6 +20,9 @@ private:
|
||||
int cmdImageList();
|
||||
int cmdImageAdd(std::vector<std::string> rest);
|
||||
int cmdImageDelete(std::vector<std::string> rest);
|
||||
int cmdDavmount(std::vector<std::string> rest);
|
||||
int cmdDavmountList();
|
||||
int cmdDavmountUmount(std::vector<std::string> rest);
|
||||
static void usage(std::FILE* out);
|
||||
|
||||
std::vector<std::string> args_;
|
||||
|
||||
+118
-3
@@ -26,6 +26,100 @@ std::string slug(std::string_view hostDir) {
|
||||
return s;
|
||||
}
|
||||
|
||||
bool noNewline(const std::string& s) { return s.find('\n') == std::string::npos; }
|
||||
|
||||
void validateMount(const MountSpec& m) {
|
||||
if (m.dst.empty() || m.dst[0] != '/' || m.dst == "/" || !noNewline(m.dst) || m.dst.find(':') != std::string::npos) {
|
||||
throw std::runtime_error("--mount destination must be an absolute path, not '/', without ':' or a newline: '" + m.dst + "'");
|
||||
}
|
||||
if (m.kind == MountKind::Bind) {
|
||||
if (m.src.empty() || m.src[0] != '/' || !noNewline(m.src) || m.src.find(':') != std::string::npos) {
|
||||
throw std::runtime_error("--mount source must be an absolute path without ':' or a newline: '" + m.src + "'");
|
||||
}
|
||||
return;
|
||||
}
|
||||
bool https = m.src.rfind("https://", 0) == 0, http = m.src.rfind("http://", 0) == 0;
|
||||
if (m.src.empty() || (!https && !http) || !noNewline(m.src)) {
|
||||
throw std::runtime_error("davmount source must be an http:// or https:// URL: '" + m.src + "'");
|
||||
}
|
||||
if (!noNewline(m.davUser) || !noNewline(m.davPass) || !noNewline(m.davVendor)) {
|
||||
throw std::runtime_error("davmount user/pass/vendor must not contain a newline");
|
||||
}
|
||||
bool knownVendor = false;
|
||||
for (auto v : spec::kDavVendors) knownVendor = knownVendor || v == m.davVendor;
|
||||
if (!knownVendor) {
|
||||
std::string known;
|
||||
for (auto v : spec::kDavVendors) known += (known.empty() ? "" : ", ") + std::string(v);
|
||||
throw std::runtime_error("unknown --dav-vendor '" + m.davVendor + "' (known: " + known + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// Current format: one key per field, indexed, so credentials may contain any character but a
|
||||
// newline (unlike the legacy single-line format below). `mounts.count=N` then, per i: `.kind`,
|
||||
// `.src`, `.dst`, `.ro`, and for webdav also `.user`, `.pass`, `.vendor`, `.insecure`.
|
||||
std::string serializeMounts(const std::vector<MountSpec>& mounts) {
|
||||
std::string out = "mounts.count=" + std::to_string(mounts.size()) + "\n";
|
||||
for (std::size_t i = 0; i < mounts.size(); ++i) {
|
||||
const MountSpec& m = mounts[i];
|
||||
std::string p = "mounts." + std::to_string(i) + ".";
|
||||
out += p + "kind=" + (m.kind == MountKind::WebDav ? "webdav" : "bind") + "\n";
|
||||
out += p + "src=" + m.src + "\n";
|
||||
out += p + "dst=" + m.dst + "\n";
|
||||
out += p + "ro=" + (m.readOnly ? "1" : "0") + "\n";
|
||||
if (m.kind == MountKind::WebDav) {
|
||||
out += p + "user=" + m.davUser + "\n";
|
||||
out += p + "pass=" + m.davPass + "\n";
|
||||
out += p + "vendor=" + m.davVendor + "\n";
|
||||
out += p + "insecure=" + (m.davInsecure ? "1" : "0") + "\n";
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Legacy format (1.5.0): a single "mounts=" line, entries separated by ';', fields by ':'
|
||||
// ("src:dst:ro"), bind mounts only. Still read so a container created under that release loads.
|
||||
std::vector<MountSpec> parseLegacyMounts(const std::string& text) {
|
||||
std::vector<MountSpec> out;
|
||||
std::istringstream in(text);
|
||||
for (std::string entry; std::getline(in, entry, ';');) {
|
||||
if (entry.empty()) continue;
|
||||
std::size_t c1 = entry.find(':');
|
||||
std::size_t c2 = entry.rfind(':');
|
||||
if (c1 == std::string::npos || c2 == c1) throw std::runtime_error("corrupt mount entry in meta: '" + entry + "'");
|
||||
MountSpec m;
|
||||
m.src = entry.substr(0, c1);
|
||||
m.dst = entry.substr(c1 + 1, c2 - c1 - 1);
|
||||
m.readOnly = entry.substr(c2 + 1) == "ro";
|
||||
out.push_back(std::move(m));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<MountSpec> parseMounts(const std::map<std::string, std::string>& kv) {
|
||||
if (!kv.count("mounts.count")) return kv.count("mounts") ? parseLegacyMounts(kv.at("mounts")) : std::vector<MountSpec>{};
|
||||
std::size_t n = std::stoul(kv.at("mounts.count"));
|
||||
if (n > spec::kMaxMounts) throw std::runtime_error("corrupt meta: too many mounts");
|
||||
std::vector<MountSpec> out;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
std::string p = "mounts." + std::to_string(i) + ".";
|
||||
auto get = [&](const char* field, const std::string& def = "") {
|
||||
auto it = kv.find(p + field);
|
||||
return it == kv.end() ? def : it->second;
|
||||
};
|
||||
MountSpec m;
|
||||
m.kind = get("kind") == "webdav" ? MountKind::WebDav : MountKind::Bind;
|
||||
m.src = get("src");
|
||||
m.dst = get("dst");
|
||||
m.readOnly = get("ro") == "1";
|
||||
m.davUser = get("user");
|
||||
m.davPass = get("pass");
|
||||
m.davVendor = get("vendor", "other");
|
||||
m.davInsecure = get("insecure") == "1";
|
||||
out.push_back(std::move(m));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> parseKv(const std::string& text) {
|
||||
std::map<std::string, std::string> kv;
|
||||
std::istringstream in(text);
|
||||
@@ -225,10 +319,12 @@ std::uint64_t Container::usedBytes(std::size_t& skipped) const {
|
||||
wr.reset();
|
||||
if (rc != 0) throw std::runtime_error("size scan failed");
|
||||
char buf[64] = {0};
|
||||
if (::read(rd.get(), buf, sizeof buf - 1) <= 0) throw std::runtime_error("size scan returned nothing");
|
||||
ssize_t rd_n = ::read(rd.get(), buf, sizeof buf - 1);
|
||||
if (rd_n <= 0) throw std::runtime_error("size scan returned nothing");
|
||||
buf[rd_n] = '\0';
|
||||
unsigned long long t = 0;
|
||||
std::size_t sk = 0;
|
||||
std::sscanf(buf, "%llu %zu", &t, &sk);
|
||||
if (std::sscanf(buf, "%llu %zu", &t, &sk) != 2) throw std::runtime_error("size scan returned malformed data");
|
||||
skipped += sk;
|
||||
return t;
|
||||
}
|
||||
@@ -238,7 +334,8 @@ void Container::writeMeta() const {
|
||||
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
|
||||
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
|
||||
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
|
||||
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n");
|
||||
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n" +
|
||||
serializeMounts(mounts_));
|
||||
}
|
||||
|
||||
void Container::setSizeCap(std::uint64_t bytes) {
|
||||
@@ -250,6 +347,15 @@ void Container::setSizeCap(std::uint64_t bytes) {
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::setMounts(const std::vector<MountSpec>& mounts) {
|
||||
if (mounts.size() > spec::kMaxMounts) {
|
||||
throw std::runtime_error("too many mounts (max " + std::to_string(spec::kMaxMounts) + ")");
|
||||
}
|
||||
for (const auto& m : mounts) validateMount(m);
|
||||
mounts_ = mounts;
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
|
||||
std::optional<std::uint32_t> milliCpus) {
|
||||
if (memory) {
|
||||
@@ -285,6 +391,13 @@ void Container::create(const NetMode& net, const std::string& image, IdMode ids,
|
||||
pids_ = spec::kDefaultPids;
|
||||
milliCpus_ = 0;
|
||||
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
|
||||
if (o.mounts) {
|
||||
if (o.mounts->size() > spec::kMaxMounts) {
|
||||
throw std::runtime_error("too many mounts (max " + std::to_string(spec::kMaxMounts) + ")");
|
||||
}
|
||||
for (const auto& m : *o.mounts) validateMount(m);
|
||||
mounts_ = *o.mounts;
|
||||
}
|
||||
created_ = std::time(nullptr);
|
||||
fsutil::mkdirs(dir_);
|
||||
prepareLayout();
|
||||
@@ -307,6 +420,7 @@ void Container::readMeta() {
|
||||
pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids;
|
||||
milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0;
|
||||
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
|
||||
mounts_ = parseMounts(kv);
|
||||
}
|
||||
|
||||
std::optional<pid_t> Container::runningPid() const {
|
||||
@@ -397,6 +511,7 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default");
|
||||
if (opts.size && *opts.size != c.sizeCap()) c.setSizeCap(*opts.size);
|
||||
if (opts.memory || opts.pids || opts.milliCpus) c.setLimits(opts.memory, opts.pids, opts.milliCpus);
|
||||
if (opts.mounts) c.setMounts(*opts.mounts);
|
||||
};
|
||||
|
||||
if (fs::exists(dir / "meta", ec)) {
|
||||
|
||||
@@ -15,6 +15,26 @@
|
||||
|
||||
namespace doos {
|
||||
|
||||
// One --mount entry: bind-mounted at `dst` inside the container, in addition to the usual
|
||||
// overlay/image root. Not subject to the --size cap: it is host storage, not the container's own
|
||||
// writable layer.
|
||||
// Bind: --mount SRC:DST[:ro] SRC is an existing host directory.
|
||||
// WebDav: --mount URL DST[:ro] --davmount URL DST[:ro] (URL: dav://, davs://, http://
|
||||
// or https://). `src` holds the http(s) URL rclone mounts on demand (see davmount.hpp);
|
||||
// doos never speaks WebDAV itself, it only drives a managed rclone binary and then
|
||||
// bind-mounts the result exactly like a Bind entry.
|
||||
enum class MountKind { Bind, WebDav };
|
||||
struct MountSpec {
|
||||
MountKind kind = MountKind::Bind;
|
||||
std::string src; // Bind: host path. WebDav: the http(s) URL (credentials never embedded here).
|
||||
std::string dst; // absolute path inside the container
|
||||
bool readOnly = false;
|
||||
std::string davUser; // WebDav only, optional
|
||||
std::string davPass; // WebDav only, optional, plaintext in meta (see SPEC.md); obscured just before rclone runs
|
||||
std::string davVendor; // WebDav only, one of spec::kDavVendors, default "other"
|
||||
bool davInsecure = false; // WebDav only: skip TLS certificate verification
|
||||
};
|
||||
|
||||
// What the user asked for. Unset fields mean "default on create, no constraint on existing".
|
||||
struct Options {
|
||||
std::optional<NetMode> net;
|
||||
@@ -25,6 +45,7 @@ struct Options {
|
||||
std::optional<std::uint32_t> pids;
|
||||
std::optional<std::uint32_t> milliCpus; // 0 = all cores
|
||||
std::optional<bool> relaxed; // --hardening relaxed
|
||||
std::optional<std::vector<MountSpec>> mounts; // --mount, replaces the whole list when given
|
||||
};
|
||||
|
||||
|
||||
@@ -56,8 +77,15 @@ public:
|
||||
std::uint32_t pidsCap() const { return pids_; }
|
||||
std::uint32_t milliCpus() const { return milliCpus_; }
|
||||
bool relaxed() const { return relaxed_; }
|
||||
const std::vector<MountSpec>& mounts() const { return mounts_; }
|
||||
// Limits take effect the next time the container starts.
|
||||
void setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus);
|
||||
// Replaces the whole mount list; takes effect the next time the container starts.
|
||||
void setMounts(const std::vector<MountSpec>& mounts);
|
||||
// Runtime-only: swaps in already-resolved Bind entries (WebDav resolved to its managed mount
|
||||
// directory) right before the container starts. Never persisted; the configured list (with the
|
||||
// original URL) is untouched on disk. Used only by Sandbox::start.
|
||||
void primeRuntimeMounts(std::vector<MountSpec> resolved) { mounts_ = std::move(resolved); }
|
||||
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
|
||||
void readMeta(); // meta only, no side effects
|
||||
@@ -89,6 +117,7 @@ private:
|
||||
std::uint32_t pids_ = 0;
|
||||
std::uint32_t milliCpus_ = 0;
|
||||
bool relaxed_ = false;
|
||||
std::vector<MountSpec> mounts_;
|
||||
};
|
||||
|
||||
class Store {
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
#include "davmount.hpp"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/utsname.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
fs::path stateHome() {
|
||||
if (const char* h = std::getenv("DOOS_HOME"); h && *h) return h;
|
||||
if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) return fs::path(x) / "doos";
|
||||
if (const char* home = std::getenv("HOME"); home && *home) return fs::path(home) / ".local/share/doos";
|
||||
throw std::runtime_error("HOME is not set; set DOOS_HOME");
|
||||
}
|
||||
|
||||
fs::path davRoot() { return stateHome() / "davmounts"; }
|
||||
|
||||
std::string hostArch() {
|
||||
utsname u;
|
||||
if (::uname(&u) != 0) throw SysError("uname");
|
||||
std::string m = u.machine;
|
||||
if (m == "x86_64") return "amd64";
|
||||
if (m == "aarch64") return "arm64";
|
||||
throw std::runtime_error("no rclone release for architecture " + m + "; davmount needs x86_64 or aarch64");
|
||||
}
|
||||
|
||||
std::string trimmed(std::string s) {
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Stable short id for a WebDAV target (url + credentials + vendor + insecure flag), not including
|
||||
// the container-specific destination: the same target reuses one mount across containers.
|
||||
std::string mountId(const MountSpec& m) {
|
||||
std::string key = m.src + "\x01" + m.davUser + "\x01" + m.davPass + "\x01" + m.davVendor + "\x01" +
|
||||
(m.davInsecure ? "1" : "0");
|
||||
std::uint64_t h = 1469598103934665603ULL; // FNV-1a 64-bit offset basis
|
||||
for (unsigned char c : key) {
|
||||
h ^= c;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
char buf[17];
|
||||
std::snprintf(buf, sizeof buf, "%016llx", static_cast<unsigned long long>(h));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// rclone's on-the-fly connection-string syntax quotes a value in "..." with internal quotes
|
||||
// doubled; always quoting is safe for every field we pass (url, vendor, user, pass).
|
||||
std::string quoteConnVal(const std::string& v) {
|
||||
std::string out = "\"";
|
||||
for (char c : v) {
|
||||
if (c == '"') out += "\"\"";
|
||||
else out += c;
|
||||
}
|
||||
out += "\"";
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string buildConnString(const MountSpec& m, const std::string& obscuredPass) {
|
||||
std::string vendor = m.davVendor.empty() ? "other" : m.davVendor;
|
||||
std::string conn = ":webdav,url=" + quoteConnVal(m.src) + ",vendor=" + quoteConnVal(vendor);
|
||||
if (!m.davUser.empty()) conn += ",user=" + quoteConnVal(m.davUser);
|
||||
if (!obscuredPass.empty()) conn += ",pass=" + quoteConnVal(obscuredPass);
|
||||
if (m.davInsecure) conn += ",no_check_certificate=" + quoteConnVal("true");
|
||||
conn += ":";
|
||||
return conn;
|
||||
}
|
||||
|
||||
// Mount point is always the 5th whitespace-separated field of a mountinfo line; the filesystem
|
||||
// type is the token right after the first "-" separator (the optional-fields region before it has
|
||||
// a variable length). Same technique as the CVE-2019-16884 audit check.
|
||||
bool mountInfoHasReadyFuse(const fs::path& mnt) {
|
||||
std::error_code ec;
|
||||
std::string want = fs::exists(mnt, ec) ? fs::canonical(mnt, ec).string() : mnt.string();
|
||||
std::ifstream in("/proc/self/mountinfo");
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
std::istringstream ls(line);
|
||||
std::vector<std::string> tok;
|
||||
for (std::string t; ls >> t;) tok.push_back(t);
|
||||
if (tok.size() < 5) continue;
|
||||
if (tok[4] != want) continue;
|
||||
auto it = std::find(tok.begin(), tok.end(), "-");
|
||||
if (it == tok.end() || it + 1 == tok.end()) continue;
|
||||
if (it[1].rfind("fuse", 0) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Root inode stat() always succeeds under FUSE without touching the backend; only a real listing
|
||||
// forces the PROPFIND round trip and surfaces auth/connectivity failures as EIO.
|
||||
bool probeReadable(const fs::path& mnt) {
|
||||
DIR* d = ::opendir(mnt.c_str());
|
||||
if (!d) return false;
|
||||
errno = 0;
|
||||
(void)::readdir(d); // only errno matters here: did the backend round trip succeed
|
||||
bool ok = (errno == 0);
|
||||
::closedir(d);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void unmountQuiet(const fs::path& mnt) {
|
||||
std::string bin;
|
||||
try {
|
||||
bin = resolveBinary("fusermount3");
|
||||
} catch (const std::exception&) {
|
||||
try {
|
||||
bin = resolveBinary("fusermount");
|
||||
} catch (const std::exception&) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
runProcess({bin, "-u", mnt.string()}, false, true);
|
||||
if (mountInfoHasReadyFuse(mnt)) runProcess({bin, "-uz", mnt.string()}, false, true);
|
||||
}
|
||||
|
||||
std::string tailOfLog(const fs::path& log) {
|
||||
std::error_code ec;
|
||||
if (!fs::exists(log, ec)) return "";
|
||||
try {
|
||||
std::string content = fsutil::readFile(log);
|
||||
if (content.size() > 2000) content = content.substr(content.size() - 2000);
|
||||
return content;
|
||||
} catch (const std::exception&) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
std::string classifyFailure(const std::string& log) {
|
||||
auto has = [&](const char* s) { return log.find(s) != std::string::npos; };
|
||||
if (has("401") || has("Unauthorized")) return "authentication failed (check --dav-user/--dav-pass)";
|
||||
if (has("x509") || has("certificate")) return "TLS certificate error (pass --dav-insecure for a trusted self-signed server)";
|
||||
if (has("Unsupported method") || has("501")) return "the server does not look like a WebDAV endpoint (check the URL and --dav-vendor)";
|
||||
if (has("no such host") || has("dial tcp") || has("connection refused") || has("i/o timeout") || has("Temporary failure")) {
|
||||
return "cannot reach the server (check the URL and network)";
|
||||
}
|
||||
return log.empty() ? "mount did not become ready in time" : "see the log for details";
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> readKv(const fs::path& p) {
|
||||
std::map<std::string, std::string> kv;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(p, ec)) return kv;
|
||||
try {
|
||||
std::istringstream in(fsutil::readFile(p));
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
auto eq = line.find('=');
|
||||
if (eq != std::string::npos) kv[line.substr(0, eq)] = line.substr(eq + 1);
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return kv;
|
||||
}
|
||||
|
||||
// Downloads, verifies and installs rclone under the state dir on first use; a fast version check
|
||||
// short-circuits every call after that. Never resolved via PATH or a system package: doos controls
|
||||
// exactly which rclone build it drives.
|
||||
std::string ensureRclone() {
|
||||
std::string ver(spec::kRcloneVersion);
|
||||
fs::path dir = stateHome() / "tools" / ("rclone-" + ver);
|
||||
fs::path bin = dir / "rclone";
|
||||
|
||||
auto versionOk = [&]() -> bool {
|
||||
struct stat st;
|
||||
if (::stat(bin.c_str(), &st) != 0 || !S_ISREG(st.st_mode) || ::access(bin.c_str(), X_OK) != 0) return false;
|
||||
ProcResult v = runProcess({bin.string(), "version"}, true, true);
|
||||
return v.code == 0 && v.out.rfind("rclone " + ver, 0) == 0;
|
||||
};
|
||||
if (versionOk()) return bin.string();
|
||||
|
||||
fsutil::mkdirs(dir.parent_path(), 0700);
|
||||
FileLock lock(dir.parent_path() / ".install.lock", 120000);
|
||||
if (versionOk()) return bin.string(); // another process installed it while we waited for the lock
|
||||
|
||||
std::string arch = hostArch();
|
||||
std::string base = std::string(spec::kRcloneBaseUrl) + ver + "/";
|
||||
std::string file = "rclone-" + ver + "-linux-" + arch + ".zip";
|
||||
|
||||
TempDir tmp;
|
||||
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||
verifyClearsignedSignature(tmp.path() / "SHA256SUMS", std::string(spec::kRcloneKeyUrl), std::string(spec::kRcloneKeyFpr));
|
||||
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||
std::string wantSha;
|
||||
{
|
||||
std::istringstream in(sums);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
auto sp = line.find(" ");
|
||||
if (sp == std::string::npos) continue;
|
||||
std::string sha = line.substr(0, sp), fn = line.substr(sp + 2);
|
||||
if (fn == file && isHex64(sha)) { wantSha = sha; break; }
|
||||
}
|
||||
}
|
||||
if (wantSha.empty()) {
|
||||
throw std::runtime_error("rclone " + ver + " publishes no checksum for " + file + " (unsupported architecture?)");
|
||||
}
|
||||
|
||||
fetchFile(base + file, tmp.path() / "rclone.zip", spec::kMaxRcloneDownloadBytes);
|
||||
ProcResult h = runProcess({resolveBinary("sha256sum"), (tmp.path() / "rclone.zip").string()}, true);
|
||||
std::string got = h.out.substr(0, 64);
|
||||
if (h.code != 0 || got != wantSha) {
|
||||
throw std::runtime_error("sha256 mismatch for rclone " + ver + ": expected " + wantSha + ", got " + got);
|
||||
}
|
||||
|
||||
fs::path extractDir = tmp.path() / "extract";
|
||||
fsutil::mkdirs(extractDir);
|
||||
std::string unzip;
|
||||
try {
|
||||
unzip = resolveBinary("unzip");
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error("davmount needs 'unzip' installed to unpack rclone's release archive");
|
||||
}
|
||||
ProcResult u = runProcess({unzip, "-q", "-o", (tmp.path() / "rclone.zip").string(), "-d", extractDir.string()});
|
||||
if (u.code != 0) throw std::runtime_error("unzip failed for rclone " + ver + " (exit " + std::to_string(u.code) + ")");
|
||||
|
||||
fs::path extracted = extractDir / ("rclone-" + ver + "-linux-" + arch) / "rclone";
|
||||
if (!fs::is_regular_file(extracted)) {
|
||||
throw std::runtime_error("unexpected rclone archive layout: " + extracted.string() + " not found");
|
||||
}
|
||||
|
||||
fs::path tmpDest = dir.parent_path() / (".tmp-rclone-" + ver);
|
||||
std::error_code ec;
|
||||
fs::remove_all(tmpDest, ec);
|
||||
fsutil::mkdirs(tmpDest, 0755);
|
||||
fs::copy_file(extracted, tmpDest / "rclone", fs::copy_options::overwrite_existing);
|
||||
::chmod((tmpDest / "rclone").c_str(), 0755);
|
||||
|
||||
ProcResult check = runProcess({(tmpDest / "rclone").string(), "version"}, true, true);
|
||||
if (check.code != 0 || check.out.rfind("rclone " + ver, 0) != 0) {
|
||||
fs::remove_all(tmpDest, ec);
|
||||
throw std::runtime_error("freshly installed rclone did not report version " + ver);
|
||||
}
|
||||
|
||||
fs::remove_all(dir, ec);
|
||||
if (::rename(tmpDest.c_str(), dir.c_str()) != 0) throw SysError("install rclone into " + dir.string());
|
||||
std::fprintf(stderr, "doos: installed rclone %s into %s (for --mount dav://... / --davmount)\n", ver.c_str(), bin.string().c_str());
|
||||
return bin.string();
|
||||
}
|
||||
|
||||
// Double-fork daemonize: the grandchild (the real rclone mount process) is reparented to init and
|
||||
// outlives this doos invocation, which is the point of a davmount (a persistent host-level mount,
|
||||
// not tied to one container's lifetime). The intermediate child is reaped immediately.
|
||||
pid_t spawnRcloneMount(const std::string& rclone, const std::string& conn, const fs::path& mnt, const fs::path& log) {
|
||||
int pipefd[2];
|
||||
if (::pipe2(pipefd, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd readEnd(pipefd[0]), writeEnd(pipefd[1]);
|
||||
|
||||
pid_t first = ::fork();
|
||||
if (first < 0) throw SysError("fork");
|
||||
if (first == 0) {
|
||||
pid_t second = ::fork();
|
||||
if (second == 0) {
|
||||
::setsid();
|
||||
int nul = ::open("/dev/null", O_RDONLY);
|
||||
if (nul >= 0) ::dup2(nul, 0);
|
||||
int lf = ::open(log.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0600);
|
||||
if (lf >= 0) { ::dup2(lf, 1); ::dup2(lf, 2); }
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
// vfs-cache-mode=writes: WebDAV cannot stream, rclone's own docs recommend this. Its
|
||||
// default 5s write-back is shortened to 1s so a write is not left unsent for long if
|
||||
// the container that made it exits immediately after (the mount itself, and its
|
||||
// pending uploads, run detached from any container and outlive it either way).
|
||||
::execl(rclone.c_str(), rclone.c_str(), "mount", conn.c_str(), mnt.c_str(), "--allow-non-empty",
|
||||
"--vfs-cache-mode=writes", "--vfs-write-back=1s", "--log-level=INFO", static_cast<char*>(nullptr));
|
||||
::_exit(127);
|
||||
}
|
||||
if (second > 0) ::dprintf(writeEnd.get(), "%d\n", static_cast<int>(second));
|
||||
::_exit(0);
|
||||
}
|
||||
writeEnd.reset();
|
||||
int status = 0;
|
||||
::waitpid(first, &status, 0); // reap the intermediate immediately; the daemon lives on under init
|
||||
char buf[32] = {0};
|
||||
ssize_t n = ::read(readEnd.get(), buf, sizeof buf - 1);
|
||||
if (n <= 0) throw std::runtime_error("davmount: could not start rclone mount (no pid reported)");
|
||||
return static_cast<pid_t>(std::atoi(buf));
|
||||
}
|
||||
|
||||
fs::path ensureMounted(const MountSpec& m) {
|
||||
std::string id = mountId(m);
|
||||
fs::path dir = davRoot() / id;
|
||||
fsutil::mkdirs(dir, 0700);
|
||||
fs::path mnt = dir / "mnt";
|
||||
fsutil::mkdirs(mnt, 0700);
|
||||
FileLock lock(dir / "lock", spec::kDavMountReadyTimeoutMs + spec::kLockTimeoutMs);
|
||||
|
||||
auto readPid = [&]() -> pid_t {
|
||||
std::error_code ec;
|
||||
if (!fs::exists(dir / "pid", ec)) return -1;
|
||||
try {
|
||||
return static_cast<pid_t>(std::stol(trimmed(fsutil::readFile(dir / "pid"))));
|
||||
} catch (const std::exception&) {
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
if (mountInfoHasReadyFuse(mnt)) {
|
||||
pid_t pid = readPid();
|
||||
if (pid > 0 && ::kill(pid, 0) == 0 && probeReadable(mnt)) return mnt; // healthy, reuse as is
|
||||
unmountQuiet(mnt); // stale: process dead or backend unreachable
|
||||
}
|
||||
|
||||
std::string rclone = ensureRclone();
|
||||
std::string obscured;
|
||||
if (!m.davPass.empty()) {
|
||||
ProcResult r = runProcess({rclone, "obscure", m.davPass}, true, true);
|
||||
if (r.code != 0) throw std::runtime_error("rclone could not obscure the davmount password");
|
||||
obscured = trimmed(r.out);
|
||||
}
|
||||
std::string conn = buildConnString(m, obscured);
|
||||
fs::path log = dir / "log";
|
||||
pid_t daemon = spawnRcloneMount(rclone, conn, mnt, log);
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
auto deadline = Clock::now() + std::chrono::milliseconds(spec::kDavMountReadyTimeoutMs);
|
||||
bool ready = false;
|
||||
std::string earlyFail;
|
||||
while (Clock::now() < deadline) {
|
||||
if (::kill(daemon, 0) != 0) { earlyFail = tailOfLog(log); break; }
|
||||
if (mountInfoHasReadyFuse(mnt) && probeReadable(mnt)) { ready = true; break; }
|
||||
::poll(nullptr, 0, spec::kDavMountPollIntervalMs);
|
||||
}
|
||||
if (!ready) {
|
||||
if (::kill(daemon, 0) == 0) { ::kill(daemon, SIGTERM); ::poll(nullptr, 0, 200); }
|
||||
unmountQuiet(mnt);
|
||||
std::string reason = classifyFailure(earlyFail.empty() ? tailOfLog(log) : earlyFail);
|
||||
throw std::runtime_error("davmount failed for " + m.src + ": " + reason + " (log: " + log.string() + ")");
|
||||
}
|
||||
fsutil::writeFile(dir / "pid", std::to_string(daemon) + "\n");
|
||||
fsutil::writeFile(dir / "meta", "url=" + m.src + "\nvendor=" + (m.davVendor.empty() ? "other" : m.davVendor) +
|
||||
"\nuser=" + m.davUser + "\ninsecure=" + (m.davInsecure ? "1" : "0") + "\n");
|
||||
return mnt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<MountSpec> DavMount::resolveAll(const std::vector<MountSpec>& mounts) {
|
||||
std::vector<MountSpec> out;
|
||||
for (const auto& m : mounts) {
|
||||
if (m.kind == MountKind::Bind) {
|
||||
out.push_back(m);
|
||||
continue;
|
||||
}
|
||||
fs::path mnt = ensureMounted(m);
|
||||
MountSpec resolved;
|
||||
resolved.kind = MountKind::Bind;
|
||||
resolved.src = mnt.string();
|
||||
resolved.dst = m.dst;
|
||||
resolved.readOnly = m.readOnly;
|
||||
out.push_back(resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DavMount::Status> DavMount::list() {
|
||||
std::vector<Status> out;
|
||||
std::error_code ec;
|
||||
fs::path root = davRoot();
|
||||
if (!fs::exists(root, ec)) return out;
|
||||
for (const auto& e : fs::directory_iterator(root, ec)) {
|
||||
if (ec || !e.is_directory()) continue;
|
||||
std::string id = e.path().filename().string();
|
||||
if (id.rfind(".", 0) == 0) continue;
|
||||
auto kv = readKv(e.path() / "meta");
|
||||
Status s;
|
||||
s.id = id;
|
||||
s.url = kv.count("url") ? kv["url"] : "?";
|
||||
s.vendor = kv.count("vendor") ? kv["vendor"] : "other";
|
||||
s.mountpoint = (e.path() / "mnt").string();
|
||||
s.mounted = mountInfoHasReadyFuse(e.path() / "mnt");
|
||||
out.push_back(std::move(s));
|
||||
if (out.size() >= spec::kMaxDavTargets) break;
|
||||
}
|
||||
std::sort(out.begin(), out.end(), [](const Status& a, const Status& b) { return a.id < b.id; });
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string DavMount::umount(const std::string& id, bool confirmed) {
|
||||
fs::path root = davRoot();
|
||||
fs::path dir = root / id;
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(dir, ec)) throw std::runtime_error("no such davmount '" + id + "'");
|
||||
if (fs::canonical(dir, ec) != fs::canonical(root, ec) / id) {
|
||||
throw std::runtime_error("refusing to act outside the davmounts store: " + dir.string());
|
||||
}
|
||||
if (!confirmed) {
|
||||
return "dry run: would unmount and remove " + dir.string() + " (stops storage for any container still using it). Add --yes to confirm.";
|
||||
}
|
||||
FileLock lock(dir / "lock", spec::kLockTimeoutMs);
|
||||
unmountQuiet(dir / "mnt");
|
||||
fsutil::removeTree(dir);
|
||||
return "unmounted " + id;
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "container.hpp"
|
||||
|
||||
namespace doos {
|
||||
|
||||
// Turns a --mount/--davmount WebDAV entry into a ready, already-mounted host directory, then hands
|
||||
// back a plain Bind entry pointing at it; everything downstream (Sandbox, mountExtra) treats it
|
||||
// exactly like an ordinary --mount SRC:DST bind, unaware that rclone is involved at all.
|
||||
//
|
||||
// doos never speaks WebDAV itself: it drives a managed rclone binary, downloaded once, verified
|
||||
// against a pinned signing key and installed under the state dir (never the system or the
|
||||
// caller's PATH), the same trust model as Alpine/Ubuntu images. Mounts are deduplicated by target
|
||||
// (url + credentials + vendor + insecure flag) and reused across containers and attaches: the FUSE
|
||||
// mount lives on the host, outside any container's namespaces, and outlives the doos process that
|
||||
// created it.
|
||||
class DavMount {
|
||||
public:
|
||||
// Resolves every WebDav entry in `mounts`; Bind entries pass through unchanged. Installs rclone
|
||||
// and mounts on demand; reuses an already-healthy mount. Throws a specific, human-readable
|
||||
// reason (auth, connectivity, TLS, protocol) on failure, never a bare "mount failed".
|
||||
static std::vector<MountSpec> resolveAll(const std::vector<MountSpec>& mounts);
|
||||
|
||||
struct Status {
|
||||
std::string id; // directory name under $DOOS_HOME/davmounts, stable per target
|
||||
std::string url;
|
||||
std::string vendor;
|
||||
std::string mountpoint;
|
||||
bool mounted = false;
|
||||
};
|
||||
// `doos davmount list`.
|
||||
static std::vector<Status> list();
|
||||
// `doos davmount umount [--yes] ID`. Dry-run unless confirmed, like `delete`/`image delete`.
|
||||
static std::string umount(const std::string& id, bool confirmed);
|
||||
};
|
||||
|
||||
} // namespace doos
|
||||
+16
-14
@@ -20,6 +20,16 @@ namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
// Same trusted-path resolution as every other external helper (resolveBinary in util.cpp): the
|
||||
// state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
|
||||
std::string findBinary(const char* name) {
|
||||
try {
|
||||
return resolveBinary(name);
|
||||
} catch (const std::exception&) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
||||
std::string path = "/proc/" + std::to_string(pid) + "/" + file;
|
||||
int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC);
|
||||
@@ -30,16 +40,6 @@ void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
||||
}
|
||||
}
|
||||
|
||||
bool onPath(std::string_view bin) {
|
||||
const char* path = std::getenv("PATH");
|
||||
std::stringstream ss(path ? path : "/usr/bin:/bin");
|
||||
for (std::string dir; std::getline(ss, dir, ':');) {
|
||||
std::string full = dir + "/" + std::string(bin);
|
||||
if (::access(full.c_str(), X_OK) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IdMode IdMap::parse(const std::string& s) {
|
||||
@@ -71,7 +71,8 @@ std::optional<SubRange> IdMap::range(const char* file) {
|
||||
}
|
||||
|
||||
bool IdMap::subuidAvailable() {
|
||||
return range("/etc/subuid") && range("/etc/subgid") && onPath(spec::kNewUidMap) && onPath(spec::kNewGidMap);
|
||||
return range("/etc/subuid") && range("/etc/subgid") && !findBinary("newuidmap").empty() &&
|
||||
!findBinary("newgidmap").empty();
|
||||
}
|
||||
|
||||
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
|
||||
@@ -86,15 +87,16 @@ void IdMap::apply(pid_t pid, IdMode mode) {
|
||||
}
|
||||
auto u = range("/etc/subuid");
|
||||
auto g = range("/etc/subgid");
|
||||
if (!u || !g || !onPath(spec::kNewUidMap) || !onPath(spec::kNewGidMap)) {
|
||||
std::string newuidmap = findBinary("newuidmap"), newgidmap = findBinary("newgidmap");
|
||||
if (!u || !g || newuidmap.empty() || newgidmap.empty()) {
|
||||
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
|
||||
"(install uidmap, or use --ids single)");
|
||||
}
|
||||
auto n = [](const SubRange& r) { return std::to_string(std::min<unsigned long>(r.count, spec::kMaxIdRange - 1)); };
|
||||
std::string p = std::to_string(pid);
|
||||
ProcResult a = runProcess({std::string(spec::kNewUidMap), p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
|
||||
ProcResult a = runProcess({newuidmap, p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
|
||||
if (a.code != 0) throw std::runtime_error("newuidmap failed (exit " + std::to_string(a.code) + ")");
|
||||
ProcResult b = runProcess({std::string(spec::kNewGidMap), p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
|
||||
ProcResult b = runProcess({newgidmap, p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
|
||||
if (b.code != 0) throw std::runtime_error("newgidmap failed (exit " + std::to_string(b.code) + ")");
|
||||
}
|
||||
|
||||
|
||||
+16
-100
@@ -30,93 +30,6 @@ std::string cpuArch(bool debianStyle) {
|
||||
throw std::runtime_error("no catalog images for architecture " + m + "; use a file or URL with --sha256");
|
||||
}
|
||||
|
||||
std::string fetchText(const std::string& url) {
|
||||
ProcResult r = runProcess({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
||||
"--max-time", "60", url},
|
||||
true);
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
return r.out;
|
||||
}
|
||||
|
||||
void fetchFile(const std::string& url, const fs::path& out) {
|
||||
ProcResult r = runProcess({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
||||
"--max-time", "60", "--max-filesize", std::to_string(spec::kMaxCaptureBytes),
|
||||
"--output", out.string(), url});
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
}
|
||||
|
||||
// Private scratch directory, removed on scope exit.
|
||||
class TempDir {
|
||||
public:
|
||||
TempDir() {
|
||||
std::string tmpl = (fs::temp_directory_path() / "doos-XXXXXX").string();
|
||||
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
|
||||
path_ = tmpl;
|
||||
}
|
||||
~TempDir() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(path_, ec);
|
||||
}
|
||||
TempDir(const TempDir&) = delete;
|
||||
TempDir& operator=(const TempDir&) = delete;
|
||||
const fs::path& path() const { return path_; }
|
||||
|
||||
private:
|
||||
fs::path path_;
|
||||
};
|
||||
|
||||
std::string upper(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// Verifies a detached signature against a publisher key that is fetched over https and accepted only when its
|
||||
// fingerprint equals the pinned one. Returns false (with a loud note) when gpg is not installed; throws when the
|
||||
// signature is bad, missing, or made by another key.
|
||||
bool verifySignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
|
||||
if (runProcess({"sh", "-c", "command -v gpg"}, true, true).code != 0) {
|
||||
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
|
||||
return false;
|
||||
}
|
||||
TempDir home;
|
||||
::chmod(home.path().c_str(), 0700);
|
||||
fs::path keyFile = home.path() / "key.asc";
|
||||
fetchFile(keyUrl, keyFile);
|
||||
std::string h = home.path().string();
|
||||
if (runProcess({"gpg", "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
|
||||
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
|
||||
}
|
||||
ProcResult fp = runProcess({"gpg", "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
|
||||
if (fp.out.find(":::::::::" + upper(fpr) + ":") == std::string::npos) {
|
||||
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
|
||||
}
|
||||
ProcResult v = runProcess({"gpg", "--batch", "--homedir", h, "--status-fd", "1", "--verify", sig.string(), data.string()}, true, true);
|
||||
bool good = false, valid = false;
|
||||
std::istringstream in(v.out);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
|
||||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
|
||||
throw std::runtime_error("bad signature on " + data.filename().string());
|
||||
}
|
||||
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
|
||||
if (line.find("[GNUPG:] VALIDSIG") == 0) {
|
||||
std::istringstream ls(line);
|
||||
std::vector<std::string> tok;
|
||||
for (std::string t; ls >> t;) tok.push_back(upper(t));
|
||||
if (tok.size() > 2 && (tok[2] == upper(fpr) || tok.back() == upper(fpr))) valid = true;
|
||||
}
|
||||
}
|
||||
if (v.code != 0 || !good || !valid) {
|
||||
throw std::runtime_error("signature check failed for " + data.filename().string() + " (not signed by " + fpr + ")");
|
||||
}
|
||||
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isHex64(const std::string& s) {
|
||||
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
|
||||
}
|
||||
|
||||
std::vector<unsigned long> versionParts(const std::string& v) {
|
||||
std::vector<unsigned long> out;
|
||||
std::stringstream ss(v);
|
||||
@@ -201,7 +114,7 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
TempDir tmp;
|
||||
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
|
||||
verifySignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
||||
verifyDetachedSignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
||||
std::string(spec::kUbuntuKeyFpr));
|
||||
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||
std::string suffix = "-base-" + arch + ".tar.gz";
|
||||
@@ -289,10 +202,13 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
if (count >= spec::kMaxImages) {
|
||||
throw std::runtime_error("image limit reached (" + std::to_string(spec::kMaxImages) + "); delete one first");
|
||||
}
|
||||
for (const char* tool : {"curl", "tar", "sha256sum"}) {
|
||||
if (runProcess({"sh", "-c", std::string("command -v ") + tool}, true, true).code != 0) {
|
||||
throw std::runtime_error(std::string("required tool not found: ") + tool);
|
||||
}
|
||||
std::string curl, tar, sha256sum;
|
||||
try {
|
||||
curl = resolveBinary("curl");
|
||||
tar = resolveBinary("tar");
|
||||
sha256sum = resolveBinary("sha256sum");
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(std::string("required tool missing: ") + e.what());
|
||||
}
|
||||
fs::path tmp = root_ / (".tmp-" + src.name);
|
||||
{
|
||||
@@ -312,13 +228,13 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
} else {
|
||||
archive = tmp / "download";
|
||||
std::fprintf(stderr, "downloading %s\n", src.location.c_str());
|
||||
ProcResult d = runProcess({"curl", "--fail", "--location", "--proto", "=https", "--progress-bar",
|
||||
ProcResult d = runProcess({curl, "--fail", "--location", "--proto", "=https", "--progress-bar",
|
||||
"--max-filesize", std::to_string(spec::kMaxImageDownloadBytes), "--max-time",
|
||||
std::to_string(spec::kDownloadTimeoutSec), "--output", archive.string(), src.location});
|
||||
if (d.code != 0) throw std::runtime_error("download failed (curl exit " + std::to_string(d.code) + ")");
|
||||
}
|
||||
if (!src.sha256.empty()) {
|
||||
ProcResult h = runProcess({"sha256sum", archive.string()}, true);
|
||||
ProcResult h = runProcess({sha256sum, archive.string()}, true);
|
||||
std::string got = h.out.substr(0, 64);
|
||||
if (h.code != 0 || got != lowerCase(src.sha256)) {
|
||||
throw std::runtime_error("sha256 mismatch for " + src.location + ": expected " + src.sha256 + ", got " + got);
|
||||
@@ -327,22 +243,22 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
if (!src.sigUrl.empty()) {
|
||||
TempDir sigDir;
|
||||
fetchFile(src.sigUrl, sigDir.path() / "file.asc");
|
||||
verifySignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
||||
verifyDetachedSignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
|
||||
}
|
||||
fs::path root = tmp / "rootfs";
|
||||
fsutil::mkdirs(root, 0755);
|
||||
std::vector<std::string> tar = {"tar", "-x", "-p", "--numeric-owner", "--exclude=./dev/*", "--exclude=dev/*",
|
||||
"-f", archive.string(), "-C", root.string()};
|
||||
if (ids == IdMode::Single) tar.insert(tar.begin() + 1, "--no-same-owner");
|
||||
std::vector<std::string> tarArgv = {tar, "-x", "-p", "--numeric-owner", "--exclude=./dev/*", "--exclude=dev/*",
|
||||
"-f", archive.string(), "-C", root.string()};
|
||||
if (ids == IdMode::Single) tarArgv.insert(tarArgv.begin() + 1, "--no-same-owner");
|
||||
std::fprintf(stderr, "extracting (ids: %s)\n", IdMap::name(ids).c_str());
|
||||
int rc = UserNsJob::run(ids, [&] {
|
||||
std::vector<char*> a;
|
||||
for (auto& s : tar) a.push_back(s.data());
|
||||
for (auto& s : tarArgv) a.push_back(s.data());
|
||||
a.push_back(nullptr);
|
||||
::execvp(a[0], a.data());
|
||||
::execv(a[0], a.data());
|
||||
return 127;
|
||||
});
|
||||
if (rc != 0) throw std::runtime_error("tar failed (exit " + std::to_string(rc) + ")");
|
||||
|
||||
+20
-4
@@ -50,6 +50,12 @@ std::string Network::resolvConf(NetMode m) {
|
||||
}
|
||||
|
||||
void SlirpSession::start(pid_t targetPid) {
|
||||
std::string bin;
|
||||
try {
|
||||
bin = resolveBinary("slirp4netns");
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error("slirp4netns helper not found (install slirp4netns or use --net none)");
|
||||
}
|
||||
int ready[2];
|
||||
if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd readEnd(ready[0]), writeEnd(ready[1]);
|
||||
@@ -58,14 +64,16 @@ void SlirpSession::start(pid_t targetPid) {
|
||||
if (pid_ < 0) throw SysError("fork slirp4netns");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
// Do not trust the caller's PATH: only system directories may be searched by anything the helper runs.
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC
|
||||
std::string readyArg = "--ready-fd=" + std::to_string(wfd);
|
||||
std::string target = std::to_string(targetPid);
|
||||
std::string bin(spec::kSlirpBinary), tap(spec::kSlirpTapName);
|
||||
::execlp(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
||||
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<char*>(nullptr));
|
||||
std::string tap(spec::kSlirpTapName);
|
||||
::execl(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
||||
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<char*>(nullptr));
|
||||
::dprintf(2, "doos: cannot exec %s: %s\n", bin.c_str(), std::strerror(errno));
|
||||
::_exit(127);
|
||||
}
|
||||
@@ -92,14 +100,22 @@ void SlirpSession::stop() {
|
||||
namespace doos {
|
||||
|
||||
void PastaSession::start(pid_t targetPid) {
|
||||
std::string bin;
|
||||
try {
|
||||
bin = resolveBinary("pasta");
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error("pasta helper not found (install pasta or use --net none)");
|
||||
}
|
||||
pid_ = ::fork();
|
||||
if (pid_ < 0) throw SysError("fork pasta");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
std::string target = std::to_string(targetPid);
|
||||
::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast<char*>(nullptr));
|
||||
::execl(bin.c_str(), bin.c_str(), "--foreground", "--quiet", "--config-net", target.c_str(),
|
||||
static_cast<char*>(nullptr));
|
||||
::_exit(127);
|
||||
}
|
||||
// pasta has no ready fd: wait until the target namespace has an interface besides lo.
|
||||
|
||||
+49
-10
@@ -23,6 +23,7 @@
|
||||
#include <chrono>
|
||||
|
||||
#include "cgroup.hpp"
|
||||
#include "davmount.hpp"
|
||||
#include "hardening.hpp"
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
@@ -130,16 +131,26 @@ void mountHostLayers(const Container& c, const fs::path& root) {
|
||||
for (auto d : spec::kCompatDirs) {
|
||||
std::error_code e;
|
||||
std::string host(d);
|
||||
auto st = fs::symlink_status(host, e);
|
||||
if (e) continue;
|
||||
if (fs::is_symlink(st)) {
|
||||
fs::path target = fs::read_symlink(host);
|
||||
fs::path link = root / host.substr(1);
|
||||
fs::remove(link, e);
|
||||
fs::create_symlink(target, link);
|
||||
} else if (fs::is_directory(st)) {
|
||||
mountOverlay(c, root, d);
|
||||
// Read any symlink through an fd opened on its parent so the target cannot be swapped
|
||||
// between the check and the recreation (TOCTOU). Real directories are overlaid; absent
|
||||
// entries are skipped. Symlinks whose target we cannot pin are not recreated: fail closed.
|
||||
fs::path parent = fs::path(host).parent_path();
|
||||
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
if (pfd < 0) continue;
|
||||
Fd guard(pfd);
|
||||
std::string base = fs::path(host).filename().string();
|
||||
char target[4096];
|
||||
ssize_t n = ::readlinkat(pfd, base.c_str(), target, sizeof target - 1);
|
||||
if (n < 0) {
|
||||
if (errno == EINVAL || errno == ENOENT) {
|
||||
if (fs::is_directory(host, e) && !e) mountOverlay(c, root, d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
target[n] = '\0';
|
||||
fs::path link = root / host.substr(1);
|
||||
fs::remove(link, e);
|
||||
fs::create_symlink(target, link);
|
||||
}
|
||||
for (auto d : spec::kPrivateDirs) doMount(c.privateDir(std::string(d)).c_str(), sub(root, d), nullptr, MS_BIND);
|
||||
// overlays that live inside a private dir must be mounted after it
|
||||
@@ -156,6 +167,19 @@ void mountImageRoot(const Container& c, const fs::path& root, const fs::path& im
|
||||
for (auto d : spec::kEmptyDirs) fsutil::mkdirs(sub(root, d));
|
||||
}
|
||||
|
||||
// --mount: extra bind mounts beyond the overlay/image root, outside the --size cap.
|
||||
void mountExtra(const Container& c, const fs::path& root) {
|
||||
for (const auto& m : c.mounts()) {
|
||||
if (!fs::is_directory(m.src)) {
|
||||
throw std::runtime_error("mount source '" + m.src + "' is not a directory (mount it on the host first)");
|
||||
}
|
||||
std::string target = sub(root, m.dst);
|
||||
fsutil::mkdirs(target);
|
||||
doMount(m.src.c_str(), target, nullptr, MS_BIND | MS_REC);
|
||||
if (m.readOnly) remountReadOnly(target);
|
||||
}
|
||||
}
|
||||
|
||||
void setupRoot(const Container& c, const fs::path& imageRoot) {
|
||||
fs::path root = c.rootfs();
|
||||
doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE);
|
||||
@@ -192,6 +216,8 @@ void setupRoot(const Container& c, const fs::path& imageRoot) {
|
||||
for (auto p : spec::kProcReadOnly) remountReadOnly(sub(root, p));
|
||||
for (auto p : spec::kProcMasked) doMount((root / "dev/null").c_str(), sub(root, p), nullptr, MS_BIND);
|
||||
|
||||
mountExtra(c, root);
|
||||
|
||||
// Switch root, then drop the old one.
|
||||
if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot");
|
||||
if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root");
|
||||
@@ -269,6 +295,10 @@ int containerInit(void* arg) {
|
||||
setupRoot(c, ctx->imageRoot);
|
||||
if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname");
|
||||
if (Network::isolated(c.net())) bringLoopbackUp();
|
||||
// Hardening (inside applyLimitsAndEnv) must be the last thing before the worker is forked
|
||||
// and exec'd: everything this process still needs to do for itself (mounts, hostname,
|
||||
// loopback) has to happen first, since its seccomp filter and dropped capabilities are
|
||||
// inherited by every descendant from here on. See the matching note in Sandbox::join.
|
||||
applyLimitsAndEnv(c);
|
||||
|
||||
pid_t worker = ::fork();
|
||||
@@ -368,6 +398,9 @@ int Sandbox::start(FileLock& lock) {
|
||||
throw std::runtime_error("image '" + container_.image() + "' is missing; add it again with: doos image add");
|
||||
}
|
||||
}
|
||||
// davmount entries are resolved here, on the host, before any namespace exists: the FUSE mount
|
||||
// this installs (or reuses) lives outside the container entirely and must outlast it.
|
||||
container_.primeRuntimeMounts(DavMount::resolveAll(container_.mounts()));
|
||||
int flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD;
|
||||
if (Network::isolated(net)) flags |= CLONE_NEWNET;
|
||||
|
||||
@@ -532,6 +565,11 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
||||
if (cgroup) {
|
||||
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
|
||||
}
|
||||
// Ordering invariant: this setns loop must run before Hardening::apply() (inside
|
||||
// applyLimitsAndEnv below), never after. The seccomp filter it installs denies
|
||||
// SYS_setns outright (hardening.cpp's deniedSyscalls), and a seccomp filter is
|
||||
// inherited by every descendant for the rest of its life. Moving the hardening call
|
||||
// earlier would make every second `attach` fail to join a running container.
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) {
|
||||
throw SysError(std::string("setns ") + names[i]);
|
||||
@@ -539,7 +577,8 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
||||
}
|
||||
if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container");
|
||||
if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home");
|
||||
// setns(pid) only affects children, so fork once more.
|
||||
// setns(pid) only affects children, so fork once more. Hardening (which the setns
|
||||
// loop above must predate) is applied only in this inner child, right before exec.
|
||||
pid_t inner = ::fork();
|
||||
if (inner < 0) throw SysError("fork");
|
||||
if (inner == 0) {
|
||||
|
||||
+21
-4
@@ -12,6 +12,23 @@ constexpr std::size_t kMaxContainers = 64;
|
||||
constexpr std::size_t kMaxNameLen = 32;
|
||||
constexpr std::size_t kMaxCommandArgs = 256;
|
||||
constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap
|
||||
constexpr std::size_t kMaxMounts = 8; // extra --mount bind mounts per container
|
||||
|
||||
// davmount: --mount/--davmount with a dav://, davs://, http:// or https:// source. rclone is never
|
||||
// taken from the caller's PATH or a system package; it is downloaded once, verified, and installed
|
||||
// under the state dir, exactly like curl/tar/gpg are only ever resolved via resolveBinary(), never
|
||||
// trusted from an ambient install. Pinned the same way Alpine/Ubuntu images are: a fixed version,
|
||||
// fetched over https, checked against a signed checksum file and a key pinned by fingerprint.
|
||||
constexpr std::string_view kRcloneVersion = "v1.75.2";
|
||||
constexpr std::string_view kRcloneBaseUrl = "https://downloads.rclone.org/";
|
||||
constexpr std::string_view kRcloneKeyUrl = "https://rclone.org/KEYS";
|
||||
constexpr std::string_view kRcloneKeyFpr = "FBF737ECE9F8AB18604BD2AC93935E02FF3B54FA";
|
||||
constexpr std::uint64_t kMaxRcloneDownloadBytes = 128ULL << 20;
|
||||
constexpr int kDavMountReadyTimeoutMs = 20000; // bounded wait for a webdav mount to answer
|
||||
constexpr int kDavMountPollIntervalMs = 100;
|
||||
constexpr std::size_t kMaxDavTargets = 32; // distinct webdav mounts doos manages at once
|
||||
constexpr std::array<std::string_view, 5> kDavVendors = {"other", "nextcloud", "owncloud", "sharepoint",
|
||||
"sharepoint-ntlm"};
|
||||
constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside
|
||||
constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then
|
||||
constexpr int kLockTimeoutMs = 5000; // per-container lock wait
|
||||
@@ -47,9 +64,9 @@ constexpr std::size_t kMaxEtcCopyFiles = 50000;
|
||||
constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20;
|
||||
|
||||
// Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range.
|
||||
// newuidmap/newgidmap, like every other external helper, are found via resolveBinary() (util.cpp):
|
||||
// the state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
|
||||
constexpr std::uint32_t kMaxIdRange = 65536;
|
||||
constexpr std::string_view kNewUidMap = "newuidmap";
|
||||
constexpr std::string_view kNewGidMap = "newgidmap";
|
||||
|
||||
// Images
|
||||
constexpr std::size_t kMaxImages = 16;
|
||||
@@ -70,8 +87,8 @@ constexpr std::string_view kUbuntuKeyUrl =
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
|
||||
// Networking
|
||||
constexpr std::string_view kSlirpBinary = "slirp4netns";
|
||||
// Networking. slirp4netns and pasta are found via resolveBinary() (util.cpp), same as every other
|
||||
// external helper: the state dir's bin/ override, then a fixed list of system directories.
|
||||
constexpr std::string_view kSlirpDns = "10.0.2.3";
|
||||
constexpr std::string_view kSlirpTapName = "tap0";
|
||||
|
||||
|
||||
+196
-11
@@ -8,10 +8,12 @@
|
||||
#include <dirent.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <unordered_set>
|
||||
#include <sstream>
|
||||
|
||||
@@ -61,11 +63,19 @@ std::uint64_t parseSize(const std::string& text) {
|
||||
if (unit == "b" || unit.empty()) {
|
||||
} else {
|
||||
if (unit.size() == 2 && unit[1] == 'b') unit.pop_back();
|
||||
if (unit == "k") v <<= 10;
|
||||
else if (unit == "m") v <<= 20;
|
||||
else if (unit == "g") v <<= 30;
|
||||
else if (unit == "t") v <<= 40;
|
||||
int shift;
|
||||
if (unit == "k") shift = 10;
|
||||
else if (unit == "m") shift = 20;
|
||||
else if (unit == "g") shift = 30;
|
||||
else if (unit == "t") shift = 40;
|
||||
else throw std::runtime_error("invalid size unit in '" + text + "' (use k, m, g or t)");
|
||||
// A 64-bit shift wraps silently on a large enough value instead of producing a huge
|
||||
// number; reject anything that would not survive the shift intact rather than let it
|
||||
// land, by chance, inside whatever range the caller validates against next.
|
||||
if (v > (std::numeric_limits<std::uint64_t>::max() >> shift)) {
|
||||
throw std::runtime_error("size '" + text + "' is too large");
|
||||
}
|
||||
v <<= shift;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -116,6 +126,8 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 2); if (!capture) ::dup2(nul, 1); }
|
||||
}
|
||||
// Never trust the caller's PATH: helpers are found only in system directories.
|
||||
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||
std::vector<char*> a;
|
||||
for (const auto& x : argv) a.push_back(const_cast<char*>(x.c_str()));
|
||||
a.push_back(nullptr);
|
||||
@@ -146,6 +158,123 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string fetchText(const std::string& url) {
|
||||
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
|
||||
"--proto", "=https", "--max-time", "60", url},
|
||||
true);
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
return r.out;
|
||||
}
|
||||
|
||||
void fetchFile(const std::string& url, const fs::path& out, std::uint64_t maxBytes) {
|
||||
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
|
||||
"--proto", "=https", "--max-time", "60", "--max-filesize",
|
||||
std::to_string(maxBytes), "--output", out.string(), url});
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
}
|
||||
|
||||
TempDir::TempDir() {
|
||||
fs::path base;
|
||||
if (const char* h = std::getenv("DOOS_HOME"); h && *h) base = h;
|
||||
else if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = fs::path(x) / "doos";
|
||||
else if (const char* home = std::getenv("HOME"); home && *home) base = fs::path(home) / ".local/share/doos";
|
||||
else base = fs::temp_directory_path();
|
||||
fs::path scratch = base / "tmp";
|
||||
std::error_code ec;
|
||||
fs::create_directories(scratch, ec);
|
||||
if (ec) scratch = fs::temp_directory_path();
|
||||
std::string tmpl = (scratch / "doos-XXXXXX").string();
|
||||
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
|
||||
path_ = tmpl;
|
||||
::chmod(path_.c_str(), 0700);
|
||||
}
|
||||
|
||||
TempDir::~TempDir() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(path_, ec);
|
||||
}
|
||||
|
||||
bool isHex64(const std::string& s) {
|
||||
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string upperStr(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// Imports the publisher key from `keyUrl` into a fresh gpg homedir and checks its fingerprint is
|
||||
// exactly `fpr`. Returns the gpg binary path, or "" (with a printed note) when gpg is not installed.
|
||||
std::string importPinnedKey(const fs::path& homedir, const std::string& keyUrl, const std::string& fpr) {
|
||||
std::string gpg;
|
||||
try {
|
||||
gpg = resolveBinary("gpg");
|
||||
} catch (const std::exception&) {
|
||||
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
|
||||
return "";
|
||||
}
|
||||
::chmod(homedir.c_str(), 0700);
|
||||
fs::path keyFile = homedir / "key.asc";
|
||||
fetchFile(keyUrl, keyFile);
|
||||
std::string h = homedir.string();
|
||||
if (runProcess({gpg, "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
|
||||
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
|
||||
}
|
||||
ProcResult fp = runProcess({gpg, "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
|
||||
if (fp.out.find(":::::::::" + upperStr(fpr) + ":") == std::string::npos) {
|
||||
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
|
||||
}
|
||||
return gpg;
|
||||
}
|
||||
|
||||
// Shared GOODSIG/VALIDSIG parsing for both --verify forms (detached and clearsigned produce the
|
||||
// same --status-fd protocol lines).
|
||||
void requireGoodSignature(const ProcResult& v, const std::string& fpr, const std::string& what) {
|
||||
bool good = false, valid = false;
|
||||
std::istringstream in(v.out);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
|
||||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
|
||||
throw std::runtime_error("bad signature on " + what);
|
||||
}
|
||||
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
|
||||
if (line.find("[GNUPG:] VALIDSIG") == 0) {
|
||||
std::istringstream ls(line);
|
||||
std::vector<std::string> tok;
|
||||
for (std::string t; ls >> t;) tok.push_back(upperStr(t));
|
||||
if (tok.size() > 2 && (tok[2] == upperStr(fpr) || tok.back() == upperStr(fpr))) valid = true;
|
||||
}
|
||||
}
|
||||
if (v.code != 0 || !good || !valid) {
|
||||
throw std::runtime_error("signature check failed for " + what + " (not signed by " + fpr + ")");
|
||||
}
|
||||
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool verifyDetachedSignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
|
||||
TempDir home;
|
||||
std::string gpg = importPinnedKey(home.path(), keyUrl, fpr);
|
||||
if (gpg.empty()) return false;
|
||||
ProcResult v = runProcess({gpg, "--batch", "--homedir", home.path().string(), "--status-fd", "1", "--verify",
|
||||
sig.string(), data.string()},
|
||||
true, true);
|
||||
requireGoodSignature(v, fpr, data.filename().string());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool verifyClearsignedSignature(const fs::path& file, const std::string& keyUrl, const std::string& fpr) {
|
||||
TempDir home;
|
||||
std::string gpg = importPinnedKey(home.path(), keyUrl, fpr);
|
||||
if (gpg.empty()) return false;
|
||||
ProcResult v = runProcess({gpg, "--batch", "--homedir", home.path().string(), "--status-fd", "1", "--verify",
|
||||
file.string()},
|
||||
true, true);
|
||||
requireGoodSignature(v, fpr, file.filename().string());
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace fsutil {
|
||||
|
||||
namespace {
|
||||
@@ -171,10 +300,11 @@ void mirrorRec(const fs::path& lower, const fs::path& upper, SeedStats& st, std:
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth, const SkipFn& skip) {
|
||||
if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep: " + from.string());
|
||||
std::error_code ec;
|
||||
int dirfd = ::open(from.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
fs::directory_iterator it(from, ec), end;
|
||||
if (ec) { ++st.skipped; return; }
|
||||
if (ec) { ++st.skipped; if (dirfd >= 0) ::close(dirfd); return; }
|
||||
for (; it != end; it.increment(ec)) {
|
||||
if (ec) { ++st.skipped; return; }
|
||||
if (ec) { ++st.skipped; continue; }
|
||||
struct stat sb;
|
||||
const fs::path& src = it->path();
|
||||
fs::path dst = to / src.filename();
|
||||
@@ -188,9 +318,13 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
++st.made;
|
||||
copyRec(src, dst, st, depth + 1, skip);
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
char buf[4096];
|
||||
ssize_t n = (dirfd >= 0) ? ::readlinkat(dirfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||
if (n < 0) { ++st.skipped; continue; }
|
||||
buf[n] = '\0';
|
||||
std::error_code e2;
|
||||
fs::remove(dst, e2);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
fs::create_symlink(std::string(buf, static_cast<std::size_t>(n)), dst);
|
||||
++st.made;
|
||||
} else if (S_ISREG(sb.st_mode)) {
|
||||
if (::access(src.c_str(), R_OK) != 0) { ++st.skipped; continue; }
|
||||
@@ -205,6 +339,7 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
++st.skipped;
|
||||
}
|
||||
}
|
||||
if (dirfd >= 0) ::close(dirfd);
|
||||
}
|
||||
|
||||
struct InodeHash {
|
||||
@@ -262,9 +397,16 @@ SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vecto
|
||||
SeedStats s = copyTree(src, dst, skip);
|
||||
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
fs::path parent = src.parent_path();
|
||||
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||
char buf[4096];
|
||||
ssize_t n = (pfd >= 0) ? ::readlinkat(pfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||
if (pfd >= 0) ::close(pfd);
|
||||
if (n < 0) { ++total.skipped; continue; }
|
||||
buf[n] = '\0';
|
||||
std::error_code ec;
|
||||
fs::remove(dst, ec);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
fs::create_symlink(std::string(buf, static_cast<std::size_t>(n)), dst);
|
||||
++total.made;
|
||||
} else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) {
|
||||
std::error_code ec;
|
||||
@@ -312,9 +454,29 @@ std::uint64_t diskUsage(const fs::path& p, std::size_t& skipped) {
|
||||
}
|
||||
|
||||
void mkdirs(const fs::path& p, mode_t mode) {
|
||||
std::error_code ec;
|
||||
fs::create_directories(p, ec);
|
||||
if (ec) throw std::runtime_error("mkdir " + p.string() + ": " + ec.message());
|
||||
if (p.empty()) return;
|
||||
std::vector<std::string> comps;
|
||||
for (const auto& part : p) {
|
||||
std::string s = part.string();
|
||||
if (s.empty() || s == ".") continue;
|
||||
if (s == "..") throw std::runtime_error("mkdir " + p.string() + ": '..' not allowed");
|
||||
comps.push_back(s);
|
||||
}
|
||||
if (comps.empty()) return;
|
||||
bool absolute = p.is_absolute();
|
||||
int base = ::open(absolute ? "/" : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
if (base < 0) throw SysError("mkdir " + p.string());
|
||||
Fd guard(base);
|
||||
for (std::size_t i = 0; i < comps.size(); ++i) {
|
||||
bool last = (i + 1 == comps.size());
|
||||
mode_t m = last ? mode : 0755;
|
||||
if (::mkdirat(guard.get(), comps[i].c_str(), m) != 0 && errno != EEXIST) {
|
||||
throw SysError("mkdir " + p.string());
|
||||
}
|
||||
int nfd = ::openat(guard.get(), comps[i].c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (nfd < 0) throw SysError("mkdir " + p.string() + " (not a directory or symlink)");
|
||||
guard.reset(nfd);
|
||||
}
|
||||
::chmod(p.c_str(), mode);
|
||||
}
|
||||
|
||||
@@ -358,4 +520,27 @@ std::size_t countEntries(const fs::path& p, std::size_t cap) {
|
||||
}
|
||||
|
||||
} // namespace fsutil
|
||||
|
||||
std::string resolveBinary(const std::string& name) {
|
||||
if (name.find('/') != std::string::npos) return name;
|
||||
// The state dir's bin/ wins: it is the trusted configuration location (0700, like /etc) and lets tests
|
||||
// or a user override a helper without touching system directories. Never the caller's PATH.
|
||||
std::string base;
|
||||
if (const char* h = ::getenv("DOOS_HOME"); h && *h) base = h;
|
||||
else if (const char* x = ::getenv("XDG_DATA_HOME"); x && *x) base = std::string(x) + "/doos";
|
||||
else if (const char* home = ::getenv("HOME"); home && *home) base = std::string(home) + "/.local/share/doos";
|
||||
if (!base.empty()) {
|
||||
std::string p = base + "/bin/" + name;
|
||||
struct stat st;
|
||||
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||
}
|
||||
for (const char* dir : {"/usr/local/bin", "/usr/bin", "/usr/local/sbin", "/usr/sbin", "/bin", "/sbin"}) {
|
||||
std::string p = std::string(dir) + "/" + name;
|
||||
struct stat st;
|
||||
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||
}
|
||||
throw std::runtime_error("required helper '" + name +
|
||||
"' not found in the state dir's bin/, /usr/local/bin, /usr/bin, /usr/sbin, /bin or /sbin (install it)");
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
@@ -61,6 +61,35 @@ struct ProcResult {
|
||||
// fork+exec without a shell. Captured output is capped; stderr is inherited unless quiet.
|
||||
ProcResult runProcess(const std::vector<std::string>& argv, bool capture = false, bool quiet = false);
|
||||
|
||||
// https-only fetch via curl (resolveBinary("curl")); throws on a non-2xx result or a transfer error.
|
||||
std::string fetchText(const std::string& url);
|
||||
void fetchFile(const std::string& url, const std::filesystem::path& out, std::uint64_t maxBytes = 1 << 20);
|
||||
|
||||
// Private scratch directory under the state dir's tmp/ (falls back to the system temp dir),
|
||||
// removed on scope exit. Used for anything downloaded and verified before it is trusted.
|
||||
class TempDir {
|
||||
public:
|
||||
TempDir();
|
||||
~TempDir();
|
||||
TempDir(const TempDir&) = delete;
|
||||
TempDir& operator=(const TempDir&) = delete;
|
||||
const std::filesystem::path& path() const { return path_; }
|
||||
|
||||
private:
|
||||
std::filesystem::path path_;
|
||||
};
|
||||
|
||||
bool isHex64(const std::string& s);
|
||||
|
||||
// Verifies a detached signature (sig is a separate file from data) against a publisher key fetched
|
||||
// over https and accepted only when its fingerprint equals the pinned one. Returns false (and prints
|
||||
// a note) when gpg is not installed; throws when the signature is bad, missing, or by another key.
|
||||
bool verifyDetachedSignature(const std::filesystem::path& data, const std::filesystem::path& sig,
|
||||
const std::string& keyUrl, const std::string& fpr);
|
||||
// Same, for a clearsigned single file (hashes and signature combined in one file, e.g. rclone's
|
||||
// SHA256SUMS release artifact).
|
||||
bool verifyClearsignedSignature(const std::filesystem::path& file, const std::string& keyUrl, const std::string& fpr);
|
||||
|
||||
namespace fsutil {
|
||||
struct SeedStats {
|
||||
std::size_t made = 0;
|
||||
@@ -88,4 +117,8 @@ void removeTree(const std::filesystem::path& p, std::size_t depth = 0);
|
||||
std::size_t countEntries(const std::filesystem::path& p, std::size_t cap);
|
||||
} // namespace fsutil
|
||||
|
||||
// Resolves an external helper binary against a fixed set of trusted directories
|
||||
// (never the caller's PATH), throwing when it is not found.
|
||||
std::string resolveBinary(const std::string& name);
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+91
-9
@@ -9,7 +9,11 @@ ok() { pass=$((pass+1)); echo "ok $1"; }
|
||||
bad() { fail=$((fail+1)); echo "FAIL $1"; }
|
||||
check() { local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; }
|
||||
out() { "$DOOS" attach t -- sh -c "$1" 2>&1; }
|
||||
trap '"$DOOS" delete --yes t >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT
|
||||
trap '
|
||||
for id in $(ls "$DOOS_HOME/davmounts" 2>/dev/null); do "$DOOS" davmount umount --yes "$id" >/dev/null 2>&1; done
|
||||
"$DOOS" delete --yes t >/dev/null 2>&1
|
||||
rm -rf "$DOOS_HOME"
|
||||
' EXIT
|
||||
|
||||
check "list empty" "$DOOS" list
|
||||
check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
|
||||
@@ -50,8 +54,8 @@ wait; sleep 0.5
|
||||
"$DOOS" get_or_create --net none n >/dev/null
|
||||
"$DOOS" attach n -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && bad "net none blocks" || ok "net none blocks"
|
||||
"$DOOS" delete --yes n >/dev/null
|
||||
"$DOOS" get_or_create --net host h >/dev/null
|
||||
"$DOOS" attach h -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works"
|
||||
"$DOOS" get_or_create --net host --i-understand h >/dev/null
|
||||
"$DOOS" attach h --i-understand -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works"
|
||||
"$DOOS" delete --yes h >/dev/null
|
||||
|
||||
# delete: dry-run by default
|
||||
@@ -71,6 +75,81 @@ outs() { "$DOOS" attach s -- sh -c "$1" 2>&1; }
|
||||
check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subuid s1"
|
||||
check "bad size rejected" bash -c "! '$DOOS' get_or_create --size 1x z1"
|
||||
check "size below minimum rejected" bash -c "! '$DOOS' get_or_create --size 1m z2"
|
||||
check "size overflow rejected, not silently wrapped" bash -c "! '$DOOS' get_or_create --size 16777217t z3"
|
||||
|
||||
# --mount: extra bind mounts
|
||||
mntd=$(mktemp -d); echo host-data > "$mntd/f"
|
||||
"$DOOS" get_or_create --mount "$mntd:/data" m >/dev/null
|
||||
[ "$("$DOOS" attach m -- cat /data/f)" = "host-data" ] && ok "mount: readable inside" || bad "mount: readable inside"
|
||||
"$DOOS" attach m -- sh -c 'echo from-container > /data/g'
|
||||
[ "$(cat "$mntd/g" 2>/dev/null)" = "from-container" ] && ok "mount: writes land on host" || bad "mount: writes land on host"
|
||||
"$DOOS" delete --yes m >/dev/null
|
||||
"$DOOS" get_or_create --mount "$mntd:/data:ro" mro >/dev/null
|
||||
"$DOOS" attach mro -- sh -c 'echo x > /data/should-fail' 2>/dev/null && bad "mount: ro rejects writes" || ok "mount: ro rejects writes"
|
||||
"$DOOS" delete --yes mro >/dev/null
|
||||
check "mount: missing source rejected" bash -c "'$DOOS' get_or_create --mount /no/such/dir:/data mbad >/dev/null && ! '$DOOS' attach mbad -- true"
|
||||
"$DOOS" delete --yes mbad >/dev/null 2>&1
|
||||
check "mount: too many rejected" bash -c "! '$DOOS' get_or_create $(for i in 1 2 3 4 5 6 7 8 9; do printf -- '--mount %s:/m%s ' "$mntd" "$i"; done) mtoomany"
|
||||
rm -rf "$mntd"
|
||||
|
||||
# --mount/--davmount with a dav(s):// or http(s):// source: auto-installs a managed rclone and
|
||||
# mounts a real WebDAV target. The target here is a disposable local server, run with its own
|
||||
# throwaway rclone copy (not doos's managed one) so the two downloads stay independent.
|
||||
echo "--- davmount (network)"
|
||||
davd=$(mktemp -d); echo webdav-data > "$davd/f.txt"
|
||||
davport=$(python3 -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.bind(('127.0.0.1',0));print(s.getsockname()[1]);s.close()" 2>/dev/null)
|
||||
davtmp=$(mktemp -d)
|
||||
davok=0
|
||||
if [ -n "$davport" ] && curl -fsSL --proto '=https' https://downloads.rclone.org/v1.75.2/rclone-v1.75.2-linux-amd64.zip -o "$davtmp/rclone.zip" 2>/dev/null \
|
||||
&& unzip -q -o "$davtmp/rclone.zip" -d "$davtmp/x" 2>/dev/null; then
|
||||
davrclone="$davtmp/x/rclone-v1.75.2-linux-amd64/rclone"
|
||||
chmod +x "$davrclone" 2>/dev/null
|
||||
[ -x "$davrclone" ] && davok=1
|
||||
fi
|
||||
if [ "$davok" = 1 ]; then
|
||||
"$davrclone" serve webdav "$davd" --addr "127.0.0.1:$davport" --user davuser --pass davpass123 --log-level=ERROR >/tmp/doos-davserve.$$ 2>&1 &
|
||||
davserve=$!
|
||||
sleep 1
|
||||
|
||||
davurl="http://davuser:davpass123@127.0.0.1:$davport/"
|
||||
"$DOOS" get_or_create --mount "$davurl" /data dav1 >/dev/null
|
||||
[ "$("$DOOS" attach dav1 -- cat /data/f.txt)" = "webdav-data" ] && ok "davmount: readable inside" || bad "davmount: readable inside"
|
||||
"$DOOS" attach dav1 -- sh -c 'echo from-dav-container > /data/g.txt'
|
||||
sleep 2
|
||||
[ "$(cat "$davd/g.txt" 2>/dev/null)" = "from-dav-container" ] && ok "davmount: writes reach the webdav backend" || bad "davmount: writes reach the webdav backend"
|
||||
"$DOOS" delete --yes dav1 >/dev/null
|
||||
|
||||
"$DOOS" get_or_create --mount "$davurl" /data dav2 >/dev/null
|
||||
t0=$(date +%s%N); "$DOOS" attach dav2 -- true; t1=$(date +%s%N)
|
||||
[ $(( (t1 - t0) / 1000000 )) -lt 3000 ] && ok "davmount: reused without remounting" || bad "davmount: reused without remounting"
|
||||
"$DOOS" delete --yes dav2 >/dev/null
|
||||
|
||||
"$DOOS" get_or_create --davmount "http://127.0.0.1:$davport/" /data --dav-user davuser --dav-pass davpass123 dav3 >/dev/null
|
||||
[ "$("$DOOS" attach dav3 -- cat /data/f.txt)" = "webdav-data" ] && ok "davmount: --davmount with --dav-user/--dav-pass" || bad "davmount: --davmount with --dav-user/--dav-pass"
|
||||
"$DOOS" delete --yes dav3 >/dev/null
|
||||
|
||||
"$DOOS" get_or_create --mount "$davurl" /data:ro davro >/dev/null
|
||||
"$DOOS" attach davro -- sh -c 'echo x > /data/should-fail' 2>/dev/null && bad "davmount: ro rejects writes" || ok "davmount: ro rejects writes"
|
||||
"$DOOS" delete --yes davro >/dev/null
|
||||
|
||||
check "davmount: wrong password rejected with a clear error" bash -c "
|
||||
'$DOOS' get_or_create --mount 'http://127.0.0.1:$davport/' /data --dav-user davuser --dav-pass wrongpass davbad >/dev/null
|
||||
'$DOOS' attach davbad -- true 2>&1 | grep -q 'authentication failed'
|
||||
"
|
||||
"$DOOS" delete --yes davbad >/dev/null 2>&1
|
||||
|
||||
check "davmount: unknown vendor rejected" bash -c "! '$DOOS' get_or_create --mount 'http://127.0.0.1:$davport/' /data --dav-vendor notavendor davvendor"
|
||||
|
||||
"$DOOS" davmount list | grep -q "$davport" && ok "davmount list: shows an active target" || bad "davmount list: shows an active target"
|
||||
david=$("$DOOS" davmount list | awk -v p="$davport" '$0 ~ p {print $1}' | head -1)
|
||||
"$DOOS" davmount umount "$david" | grep -q "dry run" && ok "davmount umount: dry-run by default" || bad "davmount umount: dry-run by default"
|
||||
"$DOOS" davmount umount --yes "$david" | grep -q unmounted && ok "davmount umount: --yes removes it" || bad "davmount umount: --yes removes it"
|
||||
|
||||
kill $davserve 2>/dev/null
|
||||
else
|
||||
echo "skip: davmount tests (no network or unzip for the test webdav server)"
|
||||
fi
|
||||
rm -rf "$davd" "$davtmp" /tmp/doos-davserve.$$ 2>/dev/null
|
||||
"$DOOS" attach s -- sh -c 'apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' && ok "host mode: apt install completes" || bad "host mode: apt install completes"
|
||||
[ ! -e /usr/games/cowsay ] && ok "host untouched by apt install" || bad "host untouched by apt install"
|
||||
"$DOOS" delete --yes s >/dev/null; "$DOOS" delete --yes s1 >/dev/null
|
||||
@@ -150,9 +229,9 @@ h() { "$DOOS" attach lim -- python3 -c "$1" 2>&1; }
|
||||
"$DOOS" attach lim -- sh -c 'gcc --version >/dev/null && curl -sS -o /dev/null https://example.com && git --version >/dev/null && python3 -c "import ssl" && ping -c1 -W3 1.1.1.1 >/dev/null' && ok "common tools work under hardening" || bad "common tools work under hardening"
|
||||
[ "$("$DOOS" attach lim -- sh -c 'ls /etc | wc -l')" -lt 80 ] && ok "etc is an allowlist" || bad "etc is an allowlist"
|
||||
"$DOOS" attach lim -- sh -c 'ls /etc/shadow /etc/sudoers /etc/ssh/ssh_host_rsa_key' >/dev/null 2>&1 && bad "etc: no credentials" || ok "etc: no credentials"
|
||||
"$DOOS" get_or_create --hardening relaxed rx >/dev/null 2>&1
|
||||
"$DOOS" attach rx -- sh -c 'unshare -U true && grep -q "^Seccomp:.0" /proc/self/status' >/dev/null 2>&1 && ok "relaxed: no seccomp, nested userns allowed" || bad "relaxed: no seccomp, nested userns allowed"
|
||||
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed lim"
|
||||
"$DOOS" get_or_create --hardening relaxed --i-understand rx >/dev/null 2>&1
|
||||
"$DOOS" attach rx --i-understand -- sh -c 'unshare -U true && grep -q "^Seccomp:.0" /proc/self/status' >/dev/null 2>&1 && ok "relaxed: no seccomp, nested userns allowed" || bad "relaxed: no seccomp, nested userns allowed"
|
||||
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed --i-understand lim"
|
||||
"$DOOS" delete --yes lim >/dev/null; "$DOOS" delete --yes rx >/dev/null
|
||||
|
||||
# ---- pasta network mode
|
||||
@@ -204,12 +283,15 @@ echo "--- signatures"
|
||||
"$DOOS" image add alpine 2>&1 | grep -q "signature verified" && ok "alpine signature verified against pinned key" || bad "alpine signature verified against pinned key"
|
||||
"$DOOS" image delete --yes alpine >/dev/null
|
||||
shim=$(mktemp -d)
|
||||
mkdir -p "$DOOS_HOME/bin"
|
||||
printf '#!/bin/sh\ncase "$*" in\n *--with-colons*) echo "fpr:::::::::0482D84022F52DF1C4E7CD43293ACD0907D9495A:";;\n *--verify*) echo "[GNUPG:] GOODSIG AAAAAAAAAAAAAAAA M"; echo "[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-01-01 1 0 4 0 1 10 00 1111111111111111111111111111111111111111";;\nesac\nexit 0\n' > $shim/gpg; chmod +x $shim/gpg
|
||||
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
|
||||
rm -f "$DOOS_HOME/bin/gpg"; cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
|
||||
[ ! -e "$DOOS_HOME/images/alpine" ] && ok "refused signature leaves no image" || bad "refused signature leaves no image"
|
||||
sed -i 's/GOODSIG AAAAAAAAAAAAAAAA M/BADSIG 293ACD0907D9495A N/' $shim/gpg
|
||||
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||
rm -rf $shim
|
||||
cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||
rm -rf $shim; rm -f "$DOOS_HOME/bin/gpg"
|
||||
"$DOOS" attach --image debian d1 -- sh -c '. /etc/os-release; echo $ID; apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' 2>/dev/null | grep -q debian && ok "debian image: apt install completes" || bad "debian image: apt install completes"
|
||||
"$DOOS" delete --yes d1 >/dev/null; "$DOOS" image delete --yes debian >/dev/null
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# valgrind
|
||||
|
||||
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||
|
||||
Memcheck run against a fixed, named set of `doos` invocations, to catch real memory bugs
|
||||
(invalid reads/writes, use-after-free, definite leaks) in doos's own code: namespace setup,
|
||||
mount handling, the CLI, the image store. Separate from `audit/` (named CVEs against other
|
||||
software) and from `tests/run.sh` (behavior, not memory safety).
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
make valgrind # builds build/doos first, then runs scripts/valgrind.sh against it
|
||||
scripts/valgrind.sh PATH # run directly against a specific binary, e.g. a copy with an
|
||||
# installed AppArmor profile (see README's apparmor section)
|
||||
```
|
||||
|
||||
Reports land in `build/valgrind-reports/<step>.log`, one per invocation, kept (not deleted)
|
||||
after the run so a failure can be inspected. Each step prints `ok` or `FAIL`; the run exits
|
||||
non-zero only if valgrind itself flagged something, never because `doos` exited non-zero for
|
||||
its own reasons (an expected `delete` dry-run notice, an AppArmor block on a binary with no
|
||||
profile installed, and so on, all exit non-zero as designed and are not memory bugs).
|
||||
|
||||
## What it checks
|
||||
|
||||
A fixed, capped list (`scripts/valgrind.sh`): `list`, `get_or_create`, `attach` (twice, to
|
||||
exercise both the create path and joining an already-running container), `image list`,
|
||||
`delete --yes`. All run with `--net none` and no image, so the check needs no network and no
|
||||
download; it is about doos's own allocation code, not about what happens after it execs the
|
||||
contained command.
|
||||
|
||||
## Suppressions (`doos.supp`)
|
||||
|
||||
Six specific, named, commented allocation sites, each captured with
|
||||
`valgrind --gen-suppressions=all` against a real run and explained in the file itself: the
|
||||
clone(2) child's 1 MiB stack buffer (lifetime tied to the child process, not a scope), and five
|
||||
"an error path constructed an exception or opened a stream, then the namespace child exited
|
||||
right after" patterns (`doMount`'s thrown `SysError`'s message string and the exception object
|
||||
itself, once for a mount failure inside `setupRoot` and once for the mount that runs directly in
|
||||
`containerInit` before it, such as an AppArmor-blocked `mount bind /`; plus an `ifstream` opened
|
||||
in `containerInit`'s cold path). None of these are "definitely lost"; valgrind calls all of them
|
||||
"possibly lost" because the process ends before the normal C++ unwind would run their
|
||||
destructors, which is expected for a namespace child that is about to disappear regardless. A
|
||||
genuinely new leak anywhere else is not covered by these exact stacks and still fails the check.
|
||||
|
||||
To add a suppression for a new, understood, one-time allocation: reproduce it, add
|
||||
`--gen-suppressions=all` to the `valgrind` invocation in `scripts/valgrind.sh` temporarily, copy
|
||||
the exact block `doos.supp` already prints into the file with a name and a comment explaining
|
||||
why it is not a real leak, then remove the temporary flag. Never add a suppression you cannot
|
||||
explain in one sentence.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Anything that happens after doos execs the user's command: valgrind traces doos's own process
|
||||
tree, not an arbitrary program run inside the container.
|
||||
- Image download and extraction (`--image`): not in the fixed list, since it needs network and
|
||||
adds `curl`/`tar`/`gpg` subprocess noise that is not doos's own memory. Add a step to
|
||||
`scripts/valgrind.sh` following the same pattern if that code path needs a dedicated run.
|
||||
- `slirp4netns`/`pasta`: separate processes, not doos; see `audit/CVE-2020-7039` for how their
|
||||
memory safety is tracked instead (upstream version, not valgrind).
|
||||
|
||||
Back to [README](../README.md), [AGENTS](../AGENTS.md), [docs/RESEARCH](../docs/RESEARCH.md).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Suppressions for `make valgrind` (see scripts/valgrind.sh and valgrind/README.md).
|
||||
#
|
||||
# Each entry here is a specific, named allocation site, not a blanket "ignore leaks" rule, and
|
||||
# each one is explained. They were captured with `valgrind --gen-suppressions=all` against a
|
||||
# real `doos attach` run and trimmed to the exact stack valgrind printed; a new, different leak
|
||||
# anywhere else still fails the check. Regenerate a candidate block with
|
||||
# `valgrind --gen-suppressions=all` if doos's internals change and one of these stops matching.
|
||||
|
||||
{
|
||||
doos-clone-child-stack
|
||||
# Sandbox::start allocates the 1 MiB stack clone(2) needs for the namespace child. Its
|
||||
# lifetime is tied to that child process, not to a scope in the parent, so it is never
|
||||
# `delete[]`d: freeing it after clone() returns would be safe only once the child has copied
|
||||
# what it needs, and doing that bookkeeping would add a second code path purely to silence
|
||||
# a tool, not to fix a real leak. Confirmed "possibly lost", never "definitely lost".
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZN4doos7Sandbox5startERNS_8FileLockE
|
||||
fun:_ZN4doos7Sandbox3runEv
|
||||
fun:_ZN4doos3Cli9cmdAttachESt6vectorINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESaIS7_EE
|
||||
fun:_ZN4doos3Cli3runEv
|
||||
fun:main
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-exception-object
|
||||
# doMount's cold (error) path throws a SysError built from a std::string; when the namespace
|
||||
# child terminates right after printing that error, the in-flight exception object itself
|
||||
# (allocated by __cxa_allocate_exception) has not yet been destroyed by the unwinder. This is
|
||||
# the standard valgrind artifact for a C++ exception that is still alive when the process
|
||||
# exits, not a missing `delete`.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
fun:__cxa_allocate_exception
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-message-string
|
||||
# Same doMount error path: the message string's internal buffer, built while constructing
|
||||
# the SysError/runtime_error that is about to be thrown. Same explanation as
|
||||
# doos-mount-error-exception-object, one frame earlier in the chain.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||
fun:_ZNSt12__cow_stringC2EPKcm
|
||||
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-exception-object-top-level
|
||||
# Same explanation as doos-mount-error-exception-object, for the mount call that runs
|
||||
# directly in containerInit before setupRoot is reached (the very first "mount bind /",
|
||||
# which is what fails when AppArmor blocks user namespaces for this binary's path).
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
fun:__cxa_allocate_exception
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-mount-error-message-string-top-level
|
||||
# Same explanation as doos-mount-error-message-string, one level up: the mount call that
|
||||
# runs directly in containerInit before setupRoot is reached.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znwm
|
||||
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||
fun:_ZNSt12__cow_stringC2EPKcm
|
||||
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||
fun:clone
|
||||
}
|
||||
|
||||
{
|
||||
doos-containerinit-ifstream-buffer
|
||||
# containerInit's cold (error) path opens an ifstream (a /proc file, for a diagnostic) whose
|
||||
# libstdc++ filebuf buffer is still open when the namespace child exits right after. Same
|
||||
# "process exited before an RAII object on an about-to-unwind error path ran its destructor"
|
||||
# pattern as the two entries above, for a stream instead of an exception.
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:_Znam
|
||||
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE27_M_allocate_internal_bufferEv
|
||||
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE4openEPKcSt13_Ios_Openmode
|
||||
fun:_ZNSt14basic_ifstreamIcSt11char_traitsIcEEC1EPKcSt13_Ios_Openmode
|
||||
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv.cold
|
||||
fun:clone
|
||||
}
|
||||
Reference in New Issue
Block a user